toolroll 0.9.88 → 0.9.90

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +16 -10
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agent-board.d.ts +9 -0
  5. package/dist/agent-board.js +20 -0
  6. package/dist/agent-fence.js +20 -2
  7. package/dist/approval-authority.d.ts +7 -0
  8. package/dist/approval-authority.js +13 -2
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
  10. package/dist/browser/terminal.js +42 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +30 -30
  13. package/dist/browser-workspace.d.ts +13 -0
  14. package/dist/builder.js +5 -2
  15. package/dist/chat-actions.d.ts +5 -0
  16. package/dist/chat-actions.js +7 -1
  17. package/dist/chat-channel.js +11 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-task-actions.d.ts +1 -0
  20. package/dist/check-access.d.ts +3 -0
  21. package/dist/check-access.js +4 -1
  22. package/dist/cli-help.d.ts +1 -1
  23. package/dist/cli-help.js +6 -2
  24. package/dist/cli.d.ts +6 -0
  25. package/dist/cli.js +23 -0
  26. package/dist/coding-ui.js +4 -2
  27. package/dist/connect-cli.js +2 -1
  28. package/dist/console-instance.d.ts +34 -1
  29. package/dist/console-instance.js +124 -15
  30. package/dist/contracts/cli.d.ts +72 -0
  31. package/dist/contracts/cli.js +13 -0
  32. package/dist/contracts/console-api.d.ts +9 -2
  33. package/dist/contracts/console-api.js +9 -2
  34. package/dist/desktop-host.js +8 -3
  35. package/dist/desktop-update.js +2 -1
  36. package/dist/dispatch.d.ts +3 -0
  37. package/dist/dispatch.js +22 -2
  38. package/dist/easy-start-args.d.ts +13 -0
  39. package/dist/easy-start-args.js +24 -0
  40. package/dist/easy-start.d.ts +77 -0
  41. package/dist/easy-start.js +321 -0
  42. package/dist/egress.js +5 -0
  43. package/dist/first-run.d.ts +77 -0
  44. package/dist/first-run.js +45 -0
  45. package/dist/flow-starters.js +3 -0
  46. package/dist/flow-triggers.js +4 -0
  47. package/dist/folder-gate.d.ts +32 -0
  48. package/dist/folder-gate.js +43 -0
  49. package/dist/guides.js +1 -1
  50. package/dist/invoke.js +11 -6
  51. package/dist/laptop-background.d.ts +171 -0
  52. package/dist/laptop-background.js +550 -0
  53. package/dist/laptop-service.d.ts +111 -0
  54. package/dist/laptop-service.js +268 -0
  55. package/dist/lead-cli.js +2 -1
  56. package/dist/lead-doors.d.ts +6 -0
  57. package/dist/lead-doors.js +20 -2
  58. package/dist/live-records.d.ts +1 -1
  59. package/dist/live-records.js +3 -12
  60. package/dist/local-owner.d.ts +124 -0
  61. package/dist/local-owner.js +212 -0
  62. package/dist/login-secrets.d.ts +60 -0
  63. package/dist/login-secrets.js +195 -0
  64. package/dist/mcp-oauth.d.ts +4 -0
  65. package/dist/mcp-oauth.js +13 -7
  66. package/dist/model-choices.d.ts +43 -0
  67. package/dist/model-choices.js +80 -0
  68. package/dist/operate.d.ts +46 -1
  69. package/dist/operate.js +461 -40
  70. package/dist/person-proof.js +3 -0
  71. package/dist/project-access.js +15 -1
  72. package/dist/project-admission.d.ts +6 -1
  73. package/dist/project-admission.js +44 -7
  74. package/dist/project-identity.d.ts +8 -0
  75. package/dist/project-identity.js +43 -0
  76. package/dist/project-knowledge.d.ts +7 -7
  77. package/dist/project.d.ts +1 -0
  78. package/dist/project.js +6 -0
  79. package/dist/proposal.js +11 -1
  80. package/dist/quick-start.d.ts +3 -1
  81. package/dist/quick-start.js +6 -1
  82. package/dist/recipes.js +12 -1
  83. package/dist/release-cli.js +2 -1
  84. package/dist/runner-process.d.ts +9 -0
  85. package/dist/runner-process.js +18 -0
  86. package/dist/sample-project.d.ts +31 -0
  87. package/dist/sample-project.js +141 -0
  88. package/dist/scope.d.ts +24 -2
  89. package/dist/scope.js +74 -3
  90. package/dist/serve.js +157 -28
  91. package/dist/server/chat.js +10 -3
  92. package/dist/server/chrome.d.ts +10 -2
  93. package/dist/server/chrome.js +7 -5
  94. package/dist/server/console.css +10 -0
  95. package/dist/server/guards.d.ts +3 -0
  96. package/dist/server/guards.js +22 -2
  97. package/dist/server/http.d.ts +22 -0
  98. package/dist/server/http.js +58 -0
  99. package/dist/server/live.js +4 -14
  100. package/dist/server/mutation-key.d.ts +70 -0
  101. package/dist/server/mutation-key.js +142 -0
  102. package/dist/server/pages.js +12 -9
  103. package/dist/server/people-tokens.js +240 -20
  104. package/dist/server/remote.d.ts +2 -0
  105. package/dist/server/remote.js +19 -4
  106. package/dist/server/render-new-task.js +5 -1
  107. package/dist/server/render-pages.d.ts +2 -1
  108. package/dist/server/render-pages.js +1 -1
  109. package/dist/server/render-people.d.ts +36 -0
  110. package/dist/server/render-people.js +65 -1
  111. package/dist/server/render-projects.js +4 -2
  112. package/dist/server/render-tasks.d.ts +12 -0
  113. package/dist/server/render-tasks.js +16 -6
  114. package/dist/server/request-context.d.ts +6 -0
  115. package/dist/server/route-table.d.ts +16 -0
  116. package/dist/server/route-table.js +121 -0
  117. package/dist/server/runtime.d.ts +6 -4
  118. package/dist/server/session.d.ts +31 -1
  119. package/dist/server/session.js +12 -2
  120. package/dist/server/settings.js +63 -8
  121. package/dist/server/task-views.js +10 -1
  122. package/dist/server/tasks.d.ts +10 -0
  123. package/dist/server/tasks.js +59 -9
  124. package/dist/server/terminals.js +15 -2
  125. package/dist/server/welcome.d.ts +22 -16
  126. package/dist/server/welcome.js +150 -52
  127. package/dist/settings-groups-ui.d.ts +7 -1
  128. package/dist/settings-groups-ui.js +23 -2
  129. package/dist/settings-proofs.js +1 -0
  130. package/dist/setup-guide.d.ts +2 -2
  131. package/dist/setup-guide.js +6 -0
  132. package/dist/store.d.ts +124 -1
  133. package/dist/store.js +245 -12
  134. package/dist/surface.d.ts +10 -0
  135. package/dist/surface.js +40 -1
  136. package/dist/task-text.d.ts +6 -0
  137. package/dist/task-text.js +17 -0
  138. package/dist/teardown-git.js +16 -1
  139. package/dist/telegram-lead.js +2 -0
  140. package/dist/terminal-text.d.ts +11 -0
  141. package/dist/terminal-text.js +26 -0
  142. package/dist/toolroll-update.d.ts +8 -1
  143. package/dist/toolroll-update.js +151 -4
  144. package/dist/tui-contract.d.ts +118 -0
  145. package/dist/tui-contract.js +9 -0
  146. package/dist/tui-source.d.ts +14 -0
  147. package/dist/tui-source.js +182 -0
  148. package/dist/tui.js +129 -0
  149. package/dist/up-processes.d.ts +13 -1
  150. package/dist/up-processes.js +13 -3
  151. package/dist/welcome-facts.d.ts +18 -0
  152. package/dist/welcome-facts.js +79 -0
  153. package/dist/youiyoux-intake.js +11 -1
  154. package/package.json +4 -2
@@ -3,7 +3,7 @@ import { adapterPolicy } from "./route-policy.js";
3
3
  /** settings handlers, moved without changing their route bodies. */
4
4
  import { mintApiToken, TOKEN_DAYS } from "../api-tokens.js";
5
5
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
6
- import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
6
+ import { html, htmlString, joinHtml, postForm, reauth, replaceMarkup, scriptElement } from "../html.js";
7
7
  import { credentialsHtml, tokenShownHtml } from "../credentials-ui.js";
8
8
  import { projectName } from "../project.js";
9
9
  import { requestLimitsHtml, tokenLimitWords } from "../request-budget-ui.js";
@@ -22,10 +22,13 @@ import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcA
22
22
  import { personAuditHtml, personHref } from "../people-audit-ui.js";
23
23
  import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
24
24
  import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
25
- import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
26
- import { joinDeadPage, joinFormPage, loginPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
27
- import { liveInviteTerms } from "../store.js";
28
- import { SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
25
+ import { form, HANDOFF_STYLE, loginHref, loginReturn, page, passwordSetNext, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
26
+ import { joinDeadPage, joinFormPage, loginPage, passwordAlreadySetPage, passwordSetPage, reconnectPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
27
+ import { enrollOwnerPassword, liveInviteTerms, localOwnerBinding } from "../store.js";
28
+ import { KEY_FILL_SCRIPT } from "./mutation-key.js";
29
+ import { LOCAL_OWNER_LAPSED } from "../local-owner.js";
30
+ import { withFormToken } from "./request-context.js";
31
+ import { FULL_SESSION, SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
29
32
  import { setupCodeMatches, signupCookie, signupFromHere } from "./signup-gate.js";
30
33
  /** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
31
34
  * running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
@@ -42,7 +45,7 @@ export function sameVisitCheck(authTime, startedAt, now) {
42
45
  return Math.min(Math.max(at, startedAt), now);
43
46
  }
44
47
  export function createPeopleHandlers(runtime) {
45
- const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
48
+ const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords, browserKeys } = runtime;
46
49
  // v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
47
50
  async function sessionsPage(ctx) {
48
51
  const { url, who, response, project } = ctx;
@@ -279,11 +282,29 @@ export function createPeopleHandlers(runtime) {
279
282
  if (!passwordAllowed(held.account))
280
283
  return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
281
284
  const id = randomBytes(32).toString("hex");
282
- sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
283
- project: runtime.defaultProject, projectRevision: 1 });
284
- recordSignIn(held.account, "signed in", "one-time link");
285
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
286
- return redirect(response, "/chat");
285
+ const keyed = keyFor(request);
286
+ const base = { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
287
+ project: runtime.defaultProject, projectRevision: 1 };
288
+ if (held.kind === "local-owner") {
289
+ // Easy-start C4: a local owner's link becomes only a local-owner session, and only if local mode, read again in
290
+ // the same transaction as the session is saved, is still on at the link's generation for the same owner, and the
291
+ // owner's account generation hasn't moved (a password set since ended this road).
292
+ const made = store.transact(() => {
293
+ const now = localOwnerBinding(store);
294
+ if (now === null || now.account !== held.account || now.localGeneration !== held.localGeneration || now.accountGeneration !== held.accountGeneration)
295
+ return false;
296
+ sessions.set(id, { ...base, generation: now.accountGeneration, authority: "local-owner", localGeneration: now.localGeneration, ...keyed.fields });
297
+ return true;
298
+ });
299
+ if (!made)
300
+ return page(response, 410, loginPage("That sign-in link no longer works. Run toolroll again to open Toolroll.", "/chat", ssoOffer()));
301
+ recordSignIn(held.account, "signed in", "local link");
302
+ }
303
+ else {
304
+ sessions.set(id, { ...base, ...FULL_SESSION, ...keyed.fields });
305
+ recordSignIn(held.account, "signed in", "one-time link");
306
+ }
307
+ return signedInTo(response, keyed.key, "/chat", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
287
308
  }
288
309
  // v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
289
310
  // ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
@@ -374,11 +395,11 @@ export function createPeopleHandlers(runtime) {
374
395
  }
375
396
  const account = store.accountOf(signedIn.account);
376
397
  const id = randomBytes(32).toString("hex");
377
- sessions.set(id, { ...arrival(request), name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
398
+ const keyed = keyFor(request);
399
+ sessions.set(id, { ...arrival(request), ...FULL_SESSION, ...keyed.fields, name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
378
400
  project: runtime.defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
379
401
  recordSignIn(signedIn.account, "signed in", settings.label);
380
- response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
381
- return redirect(response, handoff.returnTo);
402
+ return signedInTo(response, keyed.key, handoff.returnTo, [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
382
403
  }
383
404
  async function signupSend(ctx) {
384
405
  const { request, response } = ctx;
@@ -428,8 +449,11 @@ export function createPeopleHandlers(runtime) {
428
449
  if (authenticated === null || !authenticated.ok)
429
450
  return page(response, 500, loginPage("The account was created, but signing in failed. Sign in with it now."));
430
451
  const id = randomBytes(32).toString("hex");
452
+ const keyed = keyFor(request);
431
453
  sessions.set(id, {
432
454
  ...arrival(request),
455
+ ...FULL_SESSION,
456
+ ...keyed.fields,
433
457
  name,
434
458
  csrf: randomBytes(32).toString("hex"),
435
459
  role: authenticated.role,
@@ -442,8 +466,7 @@ export function createPeopleHandlers(runtime) {
442
466
  // A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
443
467
  ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
444
468
  });
445
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
446
- return redirect(response, "/");
469
+ return signedInTo(response, keyed.key, "/", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
447
470
  }
448
471
  async function loginSend(ctx) {
449
472
  const { request, response } = ctx;
@@ -479,8 +502,11 @@ export function createPeopleHandlers(runtime) {
479
502
  }
480
503
  recordSignIn(name, "signed in", "browser");
481
504
  const id = randomBytes(32).toString("hex");
505
+ const keyed = keyFor(request);
482
506
  sessions.set(id, {
483
507
  ...arrival(request),
508
+ ...FULL_SESSION,
509
+ ...keyed.fields,
484
510
  name: name,
485
511
  csrf: randomBytes(32).toString("hex"),
486
512
  role: authenticated.role,
@@ -494,8 +520,7 @@ export function createPeopleHandlers(runtime) {
494
520
  // else (a phone, a proxy) it signs in and proves nothing more.
495
521
  ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
496
522
  });
497
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
498
- return redirect(response, returnTo);
523
+ return signedInTo(response, keyed.key, returnTo, [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
499
524
  }
500
525
  async function logout(ctx) {
501
526
  const { request, response } = ctx;
@@ -567,8 +592,11 @@ export function createPeopleHandlers(runtime) {
567
592
  }
568
593
  // The cookie mints only AFTER the commit — same shape as login.
569
594
  const id = randomBytes(32).toString("hex");
595
+ const keyed = keyFor(request);
570
596
  sessions.set(id, {
571
597
  ...arrival(request),
598
+ ...FULL_SESSION,
599
+ ...keyed.fields,
572
600
  name,
573
601
  csrf: randomBytes(32).toString("hex"),
574
602
  role: made.role,
@@ -579,8 +607,7 @@ export function createPeopleHandlers(runtime) {
579
607
  project: runtime.defaultProject,
580
608
  projectRevision: 1,
581
609
  });
582
- response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
583
- return redirect(response, "/");
610
+ return signedInTo(response, keyed.key, "/", [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
584
611
  }
585
612
  /** The invite code in /join/:code (the route's pattern has already proven its shape). */
586
613
  function joinCodeOf(url) {
@@ -674,6 +701,195 @@ export function createPeopleHandlers(runtime) {
674
701
  return { ok: false, message: "Choose projects available on this instance." };
675
702
  return { ok: true, projects };
676
703
  }
704
+ /** Easy-start C2: a session's key fields when it is made: on a protected loopback install, a fresh key (only its hash
705
+ * and this start's epoch kept) to deliver once; else none. */
706
+ function keyFor(request) {
707
+ const fields = { keyEpoch: null, keyHash: null };
708
+ const key = browserKeys.issue(fields, request);
709
+ return { fields, key };
710
+ }
711
+ /** A sign-in's answer: its cookies, then on to `to` — through the key's one delivery when there is a key. */
712
+ function signedInTo(response, key, to, cookies) {
713
+ if (key !== null)
714
+ return browserKeys.deliver(response, key, to, cookies);
715
+ response.setHeader("Set-Cookie", cookies.length === 1 ? cookies[0] : [...cookies]);
716
+ return redirect(response, to);
717
+ }
718
+ // Easy-start C2: Reconnect. A browser session keeps reading after a restart, but its mutation key was bound to the
719
+ // console start that issued it. A full session gets a fresh key here by typing its password again (a cookie alone
720
+ // never does: a cookie holder on another port has the cookie); a local owner, by running `toolroll open`.
721
+ async function rekeyPage(ctx) {
722
+ const { url, who, response } = ctx;
723
+ const returnTo = loginReturn(url.searchParams.get("return"));
724
+ if (who?.via !== "cookie")
725
+ return redirect(response, loginHref(returnTo));
726
+ if (!browserKeys.required())
727
+ return redirect(response, returnTo);
728
+ return page(response, 200, reconnectPage(null, returnTo, howToReconnect(who)));
729
+ }
730
+ function howToReconnect(who) {
731
+ if (who.authority !== "full")
732
+ return { kind: "command" };
733
+ if (!passwordAllowed(who.name))
734
+ return { kind: "sso", label: ssoSettings()?.label ?? "your identity provider" };
735
+ return { kind: "password", name: who.name };
736
+ }
737
+ async function rekeySend(ctx) {
738
+ const { who, request, response } = ctx;
739
+ const body = readForm(await form(request), CONSOLE_FORMS.rekey);
740
+ const returnTo = loginReturn(body.get("return"));
741
+ if (who?.via !== "cookie")
742
+ return redirect(response, loginHref(returnTo));
743
+ if (!browserKeys.required())
744
+ return redirect(response, returnTo);
745
+ // Only this console's own page: a present Origin must name it (the password below is the proof either way).
746
+ const origin = request.headers.origin;
747
+ if (typeof origin === "string" && origin !== "null" && origin !== `http://${browserKeys.canonicalHost()}`) {
748
+ return page(response, 403, reconnectPage("Reconnect from Toolroll's own page.", returnTo, howToReconnect(who)));
749
+ }
750
+ const how = howToReconnect(who);
751
+ if (how.kind !== "password")
752
+ return page(response, 403, reconnectPage(null, returnTo, how));
753
+ const source = joinSourceOf(request), at = Date.now();
754
+ const wait = signInBudget.waitFor(source, at);
755
+ if (wait > 0) {
756
+ recordSignIn(who.name, "sign-in refused", "too many tries", "reconnect");
757
+ response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
758
+ return page(response, 429, reconnectPage(`Too many tries. Try again in ${minutesWords(wait)}.`, returnTo, how));
759
+ }
760
+ // Only a password typed now: never a session, Passwordless or a remembered password (an empty one simply fails).
761
+ const proved = authenticateAccount(store, who.name, body.get("token") ?? "");
762
+ if (!proved.ok) {
763
+ if (proved.reason === "locked") {
764
+ recordSignIn(who.name, "sign-in refused", "locked", "reconnect");
765
+ response.setHeader("Retry-After", String(Math.ceil(proved.retryAfterMs / 1000)));
766
+ return page(response, 429, reconnectPage(`Too many wrong passwords. Try again in ${minutesWords(proved.retryAfterMs)}.`, returnTo, how));
767
+ }
768
+ signInBudget.failed(source, at);
769
+ recordSignIn(who.name, "sign-in refused", "wrong password", "reconnect");
770
+ return page(response, 403, reconnectPage("That password isn't right.", returnTo, how));
771
+ }
772
+ // The same person under the same credential generation and standing this session signed in with; anything else
773
+ // signs in again instead.
774
+ if (proved.generation !== who.session.generation || proved.role !== who.session.role)
775
+ return redirect(response, loginHref(returnTo));
776
+ const key = browserKeys.issue(who.session, request);
777
+ if (key === null)
778
+ return page(response, 421, reconnectPage(`Open Toolroll at http://${browserKeys.canonicalHost()} to reconnect.`, returnTo, how));
779
+ sessions.persist(who.session);
780
+ recordSignIn(who.name, "reconnected", "browser");
781
+ return browserKeys.deliver(response, key, returnTo);
782
+ }
783
+ // Easy-start C4: /password/set, the one credential act a local owner's session may make: its first password, only
784
+ // while the account has none. The answer is a fresh full session (and a fresh browser key), and on to the act that
785
+ // was refused. Every other session, and an account that has a password, is told to sign in with it.
786
+ /** A /password/set page: no-store, and on a protected loopback install the key fill rides it (its form posts the key). */
787
+ function passwordSetAnswer(response, status, document) {
788
+ response.setHeader("Cache-Control", "no-store");
789
+ if (!browserKeys.required())
790
+ return page(response, status, document);
791
+ const nonce = randomBytes(16).toString("base64");
792
+ return page(response, status, replaceMarkup(document, /<\/body><\/html>$/, () => html `${scriptElement(KEY_FILL_SCRIPT, { nonce })}</body></html>`), nonce);
793
+ }
794
+ async function passwordSetForm(ctx) {
795
+ const { url, who, response } = ctx;
796
+ // Where to go once it is set: `next` (a refusal's link), or `return` (older links), checked either way.
797
+ const returnTo = passwordSetNext(url.searchParams.get("next") ?? url.searchParams.get("return"));
798
+ if (who === null || who === undefined)
799
+ return redirect(response, loginHref(returnTo));
800
+ if (who.via !== "cookie")
801
+ return respond(response, 403, "text/plain; charset=utf-8", "This address needs a browser sign-in.");
802
+ if (who.authority === "full" || store.accountOf(who.name)?.passwordSet !== false)
803
+ return passwordSetAnswer(response, 200, passwordAlreadySetPage("A password is already set", returnTo, who.authority === "full"));
804
+ return passwordSetAnswer(response, 200, withFormToken(who.session.csrf, () => passwordSetPage({ returnTo, problem: null })));
805
+ }
806
+ async function passwordSetSend(ctx) {
807
+ const { who, request, response } = ctx;
808
+ const media = String(request.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
809
+ if (media !== "application/x-www-form-urlencoded") {
810
+ request.resume();
811
+ return respond(response, 415, "text/plain; charset=utf-8", "forms only");
812
+ }
813
+ const body = readForm(await form(request), CONSOLE_FORMS.passwordSet);
814
+ const returnTo = passwordSetNext(body.get("next") ?? body.get("return"));
815
+ if (who === null || who === undefined)
816
+ return redirect(response, loginHref(returnTo));
817
+ if (who.via !== "cookie")
818
+ return respond(response, 403, "text/plain; charset=utf-8", "This address needs a browser sign-in.");
819
+ for (const field of ["csrf", "password", "again", "next", "return"]) {
820
+ if (body.getAll(field).length > 1)
821
+ return respond(response, 400, "text/plain; charset=utf-8", `duplicated ${field} field`);
822
+ }
823
+ const account = store.accountOf(who.name);
824
+ // A full session, or an account that has a password: only signing in with it.
825
+ if (who.authority === "full" || account === null || account.passwordSet)
826
+ return passwordSetAnswer(response, 409, passwordAlreadySetPage("A password is already set", returnTo, who.authority === "full"));
827
+ const again = (problem, status) => passwordSetAnswer(response, status, withFormToken(who.session.csrf, () => passwordSetPage({ returnTo, problem })));
828
+ // The console's own page only: a present Origin (or Referer) must name it; the session's CSRF token and (on a protected
829
+ // loopback install) its browser key are proved as for every other mutation.
830
+ const origin = request.headers.origin, referer = request.headers.referer;
831
+ const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
832
+ if (named !== null && !runtime.allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]))
833
+ return again({ field: null, text: "Set your password from Toolroll's own page." }, 403);
834
+ if (body.get("csrf") !== who.session.csrf)
835
+ return again({ field: null, text: "This form had expired, so it was reloaded. Enter the password again." }, 403);
836
+ const keyed = browserKeys.check(who, request, body.sent);
837
+ if (keyed !== null)
838
+ return again({ field: null, text: keyed.message }, keyed.status);
839
+ if (account.revokedAt !== null || account.generation !== who.session.generation || !sessions.savedNow(who.session) || who.session.localGeneration === null) {
840
+ return again({ field: null, text: LOCAL_OWNER_LAPSED }, 409);
841
+ }
842
+ // Rate-limited like signing in: the same per-address budget, and each refused try spends from it.
843
+ const source = joinSourceOf(request), at = Date.now();
844
+ const wait = signInBudget.waitFor(source, at);
845
+ if (wait > 0) {
846
+ response.setHeader("Retry-After", String(Math.ceil(wait / 1000)));
847
+ return again({ field: null, text: `Too many tries. Try again in ${minutesWords(wait)}.` }, 429);
848
+ }
849
+ const password = body.get("password") ?? "";
850
+ // The strength rule sign-up and joining use.
851
+ if (password.length < SIGNUP_PASSWORD_MIN) {
852
+ signInBudget.failed(source, at);
853
+ return again({ field: "password", text: `Use at least ${SIGNUP_PASSWORD_MIN} characters.` }, 400);
854
+ }
855
+ if ((body.get("again") ?? "") !== password) {
856
+ signInBudget.failed(source, at);
857
+ return again({ field: "again", text: "The two passwords don't match. Type them again." }, 400);
858
+ }
859
+ // Hashed before the transaction (scrypt is slow); enrollOwnerPassword then checks and writes in one IMMEDIATE
860
+ // transaction: still no password, local mode on at this session's generation, the account its active owner. It ends
861
+ // every session the account had (this one included).
862
+ const enrolled = enrollOwnerPassword(store, who.name, hashPassword(password), who.session.localGeneration, clock());
863
+ if (!enrolled.ok) {
864
+ if (enrolled.reason === "already-set")
865
+ return passwordSetAnswer(response, 409, passwordAlreadySetPage("A password was just set", returnTo));
866
+ return again({ field: null, text: LOCAL_OWNER_LAPSED }, 409);
867
+ }
868
+ const cookieId = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(request.headers.cookie ?? "")?.[1];
869
+ if (cookieId !== undefined)
870
+ sessions.delete(cookieId);
871
+ recordSignIn(who.name, "signed in", "browser", "first password set");
872
+ // A fresh full session, made only after the commit, with a fresh key delivered once (no-store) on a protected install.
873
+ const id = randomBytes(32).toString("hex");
874
+ const issued = keyFor(request);
875
+ sessions.set(id, {
876
+ ...arrival(request),
877
+ ...FULL_SESSION,
878
+ ...issued.fields,
879
+ name: who.name,
880
+ csrf: randomBytes(32).toString("hex"),
881
+ role: account.role,
882
+ generation: enrolled.generation,
883
+ createdAt: Date.now(),
884
+ sawBoardAt: null,
885
+ lastSeen: Date.now(),
886
+ project: who.session.project ?? runtime.defaultProject,
887
+ projectRevision: 1,
888
+ // A password typed on this computer starts this session's freshness window (cockpit B2), as signing in does.
889
+ ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
890
+ });
891
+ return signedInTo(response, issued.key, returnTo, [`${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
892
+ }
677
893
  const registrations = handlersOf("people", {
678
894
  "people.page": peoplePage,
679
895
  "settings.sessions": sessionsPage,
@@ -691,8 +907,12 @@ export function createPeopleHandlers(runtime) {
691
907
  "edge.signup": signupSend,
692
908
  "edge.login": loginSend,
693
909
  "edge.logout": logout,
910
+ "edge.rekey-page": rekeyPage,
911
+ "edge.rekey": rekeySend,
694
912
  "edge.join": joinPage,
695
913
  "edge.join-send": joinSend,
914
+ "edge.password-set-page": passwordSetForm,
915
+ "edge.password-set": passwordSetSend,
696
916
  });
697
917
  return { registrations, takeJoinAttempt, ssoCallback, readAccessForm };
698
918
  }
@@ -51,6 +51,8 @@ export interface RemoteRuntime {
51
51
  teamsSourceBudget: SourceAdmission;
52
52
  teamsTenantBudget: SourceAdmission;
53
53
  freshFactsOf: (request: IncomingMessage, who: Who | null) => FreshFacts | null;
54
+ /** Easy-start C2: the browser mutation key (server/mutation-key.ts). */
55
+ browserKeys: import("./mutation-key.js").BrowserKeys;
54
56
  }
55
57
  export declare function createRemoteHandlers(runtime: RemoteRuntime): {
56
58
  registrations: import("./handler-registry.js").Registration[];
@@ -38,6 +38,9 @@ export function createRemoteHandlers(runtime) {
38
38
  const account = who === null ? null : store.accountOf(who.name);
39
39
  if (!who || !account || account.revokedAt !== null)
40
40
  return null;
41
+ // Easy-start C1: team conversations are a remote feature; a laptop's local owner (no password) never reaches them.
42
+ if (who.via === 'cookie' && who.authority !== 'full')
43
+ return null;
41
44
  if (who.via === 'bearer')
42
45
  passwordProvedAt.set(request, clock().getTime());
43
46
  return { name: who.name, generation: account.generation, role: account.role };
@@ -71,7 +74,7 @@ export function createRemoteHandlers(runtime) {
71
74
  return true;
72
75
  }
73
76
  const who = identify(request, false);
74
- return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
77
+ return who?.name === actor.name && who.via === 'cookie' && who.authority === 'full' && who.session.generation === actor.generation;
75
78
  },
76
79
  authorizeMutation: (request, actor) => {
77
80
  if (actor.principal !== undefined)
@@ -81,11 +84,15 @@ export function createRemoteHandlers(runtime) {
81
84
  return false;
82
85
  if (who.via === 'bearer')
83
86
  return request.headers.origin === undefined;
87
+ if (who.authority !== 'full')
88
+ return false;
84
89
  const origin = request.headers.origin, referer = request.headers.referer;
85
90
  const named = typeof origin === 'string' && origin !== 'null' ? origin : typeof referer === 'string' ? referer : null;
86
91
  if (named !== null && !allowedHost(named.replace(/^https?:\/\//, '').split('/')[0]))
87
92
  return false;
88
- return request.headers['x-csrf-token'] === who.session.csrf;
93
+ // Easy-start C2: and, on a protected loopback install, the session's key (its header), which a cookie holder on
94
+ // another port can't read.
95
+ return request.headers['x-csrf-token'] === who.session.csrf && runtime.browserKeys.check(who, request, null) === null;
89
96
  },
90
97
  execute: async (actor, input) => {
91
98
  const reply = await team.execute(actor, input);
@@ -112,7 +119,8 @@ export function createRemoteHandlers(runtime) {
112
119
  requesterKey: joinSourceOf,
113
120
  session: request => {
114
121
  const who = identify(request, false);
115
- if (who === null || who.via !== "cookie")
122
+ // Easy-start C1: connecting an outside app is a credential act; a local owner's session is never one that consents.
123
+ if (who === null || who.via !== "cookie" || who.authority !== "full")
116
124
  return null;
117
125
  return { name: who.name, role: who.role, csrf: who.session.csrf, generation: who.session.generation,
118
126
  sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider",
@@ -123,13 +131,20 @@ export function createRemoteHandlers(runtime) {
123
131
  const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
124
132
  return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
125
133
  },
134
+ // Easy-start C2: the consent's own post presents the session's key too (its form carries it, filled by the page's
135
+ // one nonce'd script while the key is required).
136
+ keyed: (request, form) => {
137
+ const who = identify(request, false);
138
+ return who !== null && who.via === "cookie" && runtime.browserKeys.check(who, request, form) === null;
139
+ },
140
+ keyFill: () => runtime.browserKeys.required(),
126
141
  // The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
127
142
  // grant (their role, their projects) is checked beside it.
128
143
  // Proved on this request's live session at the moment of consent (signed in here still, same generation), through
129
144
  // the one account step-up: a password typed now, or an explicit re-check (spent). Never a remembered password.
130
145
  confirm: (request, session, typed) => {
131
146
  const who = identify(request, false);
132
- if (who === null || who.via !== "cookie" || who.name !== session.name || who.session.generation !== session.generation)
147
+ if (who === null || who.via !== "cookie" || who.authority !== "full" || who.name !== session.name || who.session.generation !== session.generation)
133
148
  return false;
134
149
  // Connecting an outside app is a credential act: never a session (Passwordless) or a remembered password.
135
150
  return freshFacts.run(runtime.freshFactsOf(request, who), () => { const proved = proveAccountNow(store, who.name, typed, { credential: true }); return proved.ok && credentialProof(proved.method); });
@@ -8,6 +8,7 @@ import { html, joinHtml, postForm } from "../html.js";
8
8
  import { projectName } from "../project.js";
9
9
  import { screen } from "./chrome.js";
10
10
  import { btn, composer, disclose, doc, docHeader, dock, field, ideas, input, note, phoneHeader, radios, select, sendButton, textarea, toggleChip } from "./kit.js";
11
+ import { TASK_TEXT_LIMITS } from "../task-text.js";
11
12
  const FORM_ID = "nt-form";
12
13
  const PLAN_ON = "The agent reads the code and asks you anything unclear, then writes the plan.";
13
14
  const PLAN_OFF = "The agent writes the plan straight from your words. Faster for small, obvious changes.";
@@ -59,7 +60,10 @@ suggestions = []) {
59
60
  value: text, rows: 4, maxRows: 10, before: bar, error: problem,
60
61
  send: sendButton({ label: "File task" }),
61
62
  note: html `<span class="k-on-note">${PLAN_ON}</span><span class="k-off-note">${PLAN_OFF}</span>`,
62
- textAttrs: { maxlength: 200, autofocus: true },
63
+ // The box holds what a title holds: a task's words are its title, and the planner reads nothing else of them when no
64
+ // goal is given, so longer words would be lost, not kept. Filing's whole-word cut (task-text.ts titleWithin) is a
65
+ // belt for other callers only.
66
+ textAttrs: { maxlength: TASK_TEXT_LIMITS.title, autofocus: true },
63
67
  });
64
68
  const options = html `<div class="k-form">\
65
69
  ${field({ id: "nt-quality", label: "Checks", control: select({ id: "nt-quality", name: "quality-mode", items: [["default", "Default checks"], ["strict", "Strict (release)"]], current: quality, wide: true }) })}\
@@ -341,7 +341,8 @@ export type OnboardCardState = {
341
341
  why: string;
342
342
  } | {
343
343
  enabled: true;
344
- roots: readonly string[];
344
+ roots: readonly string[]; /** Easy-start: the local owner's own session (no password to type; the prover's local-owner branch proves the clone). */
345
+ localOwner?: boolean;
345
346
  record: [string, {
346
347
  nameWithOwner: string;
347
348
  rootIndex: number;
@@ -881,7 +881,7 @@ export function queueScript() {
881
881
  `function problem(task,text){var card=region.querySelector('.queue-card[data-task="'+task.replace(/["\\\\]/g,"")+'"]');if(!card)return false;` +
882
882
  `var old=card.querySelector(".queue-problem");if(old)old.remove();` +
883
883
  `var row=document.createElement("p");row.className="problem queue-problem";row.setAttribute("role","alert");row.textContent=text;card.appendChild(row);return true;}` +
884
- `fetch("/queue/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:post.toString(),redirect:"manual"})` +
884
+ `fetch("/queue/move",{method:"POST",headers:(window.soKeyHeaders||function(h){return h})({"Content-Type":"application/x-www-form-urlencoded"}),body:post.toString(),redirect:"manual"})` +
885
885
  `.then(function(r){if(r.ok)return fetch("/queue?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(f){` +
886
886
  `if(f.type==="opaqueredirect"||f.status===401||f.status===403){location.href="/login";return false;}return f.ok?f.text():null;});` +
887
887
  `if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return false;}` +
@@ -27,10 +27,46 @@ export declare function ssoStepUps(page: Html, sso: {
27
27
  label: string;
28
28
  fresh: boolean;
29
29
  }, returnTo: string): Html;
30
+ /**
31
+ * Easy start: for a local owner who has no password yet, every step-up password field (type=password with
32
+ * autocomplete="current-password", typed-only ones included) becomes "Set a password to do this." and the Set a
33
+ * password link, carrying this page on: there is no password to type. A new password, a secret or a key field is
34
+ * never one. The field's own label and hint go with it.
35
+ */
36
+ export declare function localOwnerStepUps(page: Html, setHref: string): Html;
30
37
  export declare function loginPage(problem: string | null, returnTo?: string, sso?: {
31
38
  label: string;
32
39
  operatorsOnly: boolean;
33
40
  } | null): Html;
41
+ /**
42
+ * Easy-start C2: reconnect this tab after a restart (or when it lost its key): the password again for a full session, an
43
+ * identity-provider sign-in for one that signs in there, or the terminal for a local owner (no password exists).
44
+ */
45
+ export declare function reconnectPage(problem: string | null, returnTo: string, how: {
46
+ kind: "password";
47
+ name: string;
48
+ } | {
49
+ kind: "sso";
50
+ label: string;
51
+ } | {
52
+ kind: "command";
53
+ }): Html;
54
+ /** Which /password/set field a refusal is about (null: the page itself). */
55
+ export type PasswordSetProblem = {
56
+ field: "password" | "again" | null;
57
+ text: string;
58
+ };
59
+ /**
60
+ * Easy-start C4: the local owner's first password. Two fields and one button; the form carries the session's CSRF token
61
+ * (render it inside withFormToken) and, through the page's key fill, the browser key and the act that was refused, to continue there. Neither typed
62
+ * password ever comes back on a refusal.
63
+ */
64
+ export declare function passwordSetPage(view: {
65
+ returnTo: string;
66
+ problem: PasswordSetProblem | null;
67
+ }): Html;
68
+ /** /password/set once a password exists (set earlier, or just now in another tab): sign in with it, and come back. */
69
+ export declare function passwordAlreadySetPage(title: string, returnTo: string, signedIn?: boolean): Html;
34
70
  /**
35
71
  * The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
36
72
  * command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
@@ -54,6 +54,19 @@ export function ssoStepUps(page, sso, returnTo) {
54
54
  const labelled = replaceMarkup(page, /<label>[^<]*<input\b([^>]*)>\s*<\/label>/g, (_whole, attributes) => stepUp(attributes) ? swap(attributes) : null);
55
55
  return replaceMarkup(labelled, /<input\b([^>]*)>/g, (_whole, attributes) => stepUp(attributes) ? swap(attributes) : null);
56
56
  }
57
+ /**
58
+ * Easy start: for a local owner who has no password yet, every step-up password field (type=password with
59
+ * autocomplete="current-password", typed-only ones included) becomes "Set a password to do this." and the Set a
60
+ * password link, carrying this page on: there is no password to type. A new password, a secret or a key field is
61
+ * never one. The field's own label and hint go with it.
62
+ */
63
+ export function localOwnerStepUps(page, setHref) {
64
+ const stepUp = (attributes) => /\btype="password"/.test(attributes) && /\bautocomplete="current-password"/.test(attributes);
65
+ const instead = () => html `<p class="k-note" data-local-step-up>Set a password to do this. <a class="k-link" href="${setHref}">Set a password</a></p>`;
66
+ const fields = replaceMarkup(page, /<div class="k-field"><label class="k-label" for="[^"]*">[^<]*<\/label><input\b([^>]*)>(?:<p class="k-hint"[^>]*>[^<]*<\/p>)?(?:<span class="k-err"[^>]*>[^<]*<\/span>)?<\/div>/g, (_whole, attributes) => stepUp(attributes) ? instead() : null);
67
+ const labelled = replaceMarkup(fields, /<label\b[^>]*>[^<]*(?:<span\b[^>]*>[^<]*<\/span>)?[^<]*<input\b([^>]*)>\s*<\/label>/g, (_whole, attributes) => stepUp(attributes) ? instead() : null);
68
+ return replaceMarkup(labelled, /<input\b([^>]*)>/g, (_whole, attributes) => stepUp(attributes) ? instead() : null);
69
+ }
57
70
  /** An expiry as a person reads it: "Thu 5 Nov, 10:42". */
58
71
  function expiryWords(iso) {
59
72
  const at = new Date(iso);
@@ -77,7 +90,58 @@ export function loginPage(problem, returnTo = "/", sso = null) {
77
90
  ]);
78
91
  return shell("Sign in · Toolroll", outPage({
79
92
  brand: BRAND_HTML, brandHref: "/login", title: "Sign in", body,
80
- foot: html `${sso === null ? html `Forgot it? Your first login was shown when Toolroll started, and saved beside its database as <code>up-login.txt</code> on the computer running it.<br>` : ""}No account? Ask whoever runs this Toolroll for an invite link.`,
93
+ foot: html `${sso === null ? html `Forgot it? Your first login was shown when Toolroll started, and saved beside its database in <code>.toolroll-secrets/up-login.txt</code> on the computer running it.<br>` : ""}No account? Ask whoever runs this Toolroll for an invite link.`,
94
+ }), { nav: false });
95
+ }
96
+ /**
97
+ * Easy-start C2: reconnect this tab after a restart (or when it lost its key): the password again for a full session, an
98
+ * identity-provider sign-in for one that signs in there, or the terminal for a local owner (no password exists).
99
+ */
100
+ export function reconnectPage(problem, returnTo, how) {
101
+ const back = returnTo === "/" ? null : returnTo;
102
+ const body = how.kind === "command"
103
+ ? html `<p>Run <code>toolroll open</code> on this computer to reconnect.</p>`
104
+ : how.kind === "sso"
105
+ ? btn(`Sign in with ${how.label}`, { variant: "primary", href: `/login/sso${back === null ? "" : `?return=${encodeURIComponent(back)}`}`, className: "k-wide" })
106
+ : postForm("/login/rekey", joinHtml([
107
+ outField({ id: "rekey-password", label: `Password for ${how.name}`, name: "token", type: "password", attrs: { autocomplete: "current-password", required: true, autofocus: true } }),
108
+ btn("Reconnect", { variant: "primary", type: "submit", className: "k-wide" }),
109
+ ]), { signedOut: true, returnTo: back, attrs: { class: "k-form" } });
110
+ return shell("Reconnect · Toolroll", outPage({
111
+ brand: BRAND_HTML, brandHref: "/", title: "Reconnect",
112
+ body: joinHtml([problem === null ? "" : html `<div class="k-refusal">${notice(problem)}</div>`, body]),
113
+ foot: html `Toolroll restarted, so this tab needs a fresh connection before it can make changes.`,
114
+ }), { nav: false });
115
+ }
116
+ /**
117
+ * Easy-start C4: the local owner's first password. Two fields and one button; the form carries the session's CSRF token
118
+ * (render it inside withFormToken) and, through the page's key fill, the browser key and the act that was refused, to continue there. Neither typed
119
+ * password ever comes back on a refusal.
120
+ */
121
+ export function passwordSetPage(view) {
122
+ const problem = view.problem;
123
+ const error = (field) => problem !== null && problem.field === field ? problem.text : null;
124
+ const back = view.returnTo === "/" ? null : view.returnTo;
125
+ const form = postForm("/password/set", joinHtml([
126
+ outField({ id: "password-new", label: "Password", name: "password", type: "password", hint: `At least ${SIGNUP_PASSWORD_MIN} characters.`, error: error("password"),
127
+ attrs: { autocomplete: "new-password", required: true, minlength: SIGNUP_PASSWORD_MIN, ...(problem === null ? { autofocus: true } : {}) } }),
128
+ outField({ id: "password-again", label: "Type it again", name: "again", type: "password", error: error("again"),
129
+ attrs: { autocomplete: "new-password", required: true, minlength: SIGNUP_PASSWORD_MIN } }),
130
+ btn("Set password", { variant: "primary", type: "submit", className: "k-wide" }),
131
+ ]), { hidden: { next: back }, attrs: { class: "k-form" } });
132
+ return shell("Set a password · Toolroll", outPage({
133
+ brand: BRAND_HTML, brandHref: "/", title: "Set a password",
134
+ sub: "Agents can work on their own, merge and release only after you set a password.",
135
+ body: joinHtml([problem !== null && problem.field === null ? html `<div class="k-refusal">${notice(problem.text)}</div>` : "", form,
136
+ html `<div class="k-acts">${btn("Not now", { variant: "ghost", href: view.returnTo, className: "k-wide" })}</div>`]),
137
+ }), { nav: false });
138
+ }
139
+ /** /password/set once a password exists (set earlier, or just now in another tab): sign in with it, and come back. */
140
+ export function passwordAlreadySetPage(title, returnTo, signedIn = false) {
141
+ return shell(`${title} · Toolroll`, outPage({
142
+ brand: BRAND_HTML, brandHref: "/", title, sub: signedIn ? "You're signed in with it." : "Sign in with it to continue.",
143
+ body: html `<div class="k-acts">${signedIn ? btn("Continue", { variant: "primary", href: returnTo, className: "k-wide" })
144
+ : btn("Sign in", { variant: "primary", href: returnTo === "/" ? "/login" : `/login?return=${encodeURIComponent(returnTo)}`, className: "k-wide" })}</div>`,
81
145
  }), { nav: false });
82
146
  }
83
147
  /**
@@ -73,7 +73,9 @@ export function cloneReviewHtml(onboard, problem) {
73
73
  return null;
74
74
  const [nonce, record] = onboard.record;
75
75
  const kind = "step-up";
76
- const factsNow = approvalFactsNow();
76
+ // The local owner has no password to type: the clone is its own act (add-project), proved by the session at confirm.
77
+ const local = onboard.localOwner === true;
78
+ const factsNow = local ? { ...approvalFactsNow(), passwordless: true } : approvalFactsNow();
77
79
  const size = record.diskUsageKib === null ? "Unknown" : `${Math.max(1, Math.round(record.diskUsageKib / 1024))} MiB`;
78
80
  const bigOk = record.large
79
81
  ? html `<div class="k-checks"><label><input type="checkbox" name="big-ok" value="1" required>${record.nameWithOwner} is ${record.diskUsageKib === null ? "of unknown size" : size}. Clone it anyway</label></div>`
@@ -82,7 +84,7 @@ export function cloneReviewHtml(onboard, problem) {
82
84
  status: html `<h2>Clone ${record.nameWithOwner}</h2>`,
83
85
  text: html `Review before anything is downloaded. Clones into ${mono(shortPathOf(record.target))} and adds it as a project. Large-file (LFS) objects aren’t downloaded.`,
84
86
  acts: approveForm({ action: "/projects/onboard-confirm", kind, facts: factsNow, label: "Clone and add", form: { hidden: { nonce } }, before: bigOk }),
85
- terms: `${approvalTerms(kind, factsNow)} The review expires in 10 minutes.`,
87
+ terms: `${local ? "No password needed on this computer." : approvalTerms(kind, factsNow)} The review expires in 10 minutes.`,
86
88
  err: problem === null ? null : refused(problem),
87
89
  attrs: { "aria-label": `Clone ${record.nameWithOwner}` },
88
90
  })}${facts([["Repository", mono(`github.com/${record.nameWithOwner}`)], ["Size", size], ["Into", mono(shortPathOf(record.target))]])}`;