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
package/dist/scope.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS, type EvidenceKind } from "./contracts/acceptance-terms.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { type LocalTransport } from "./local-owner.js";
3
4
  import { isAcpProfile, type AcpProviderId } from "./acp-agents.js";
4
5
  export { isAcpProfile };
5
6
  import { type NetworkProfile } from "./egress.js";
@@ -651,7 +652,9 @@ export type FreshOp = "approve-standard" | "chat-send" | "reply-question";
651
652
  /** "session": Passwordless is on and this is the person's own live browser session (docs/plans/passwordless.md). */
652
653
  /** "remote-token" / "lead-token": a machine credential stood in for the person (a remote run's API token, a lead's
653
654
  * token) — fine for ordinary acts it is allowed, never for a credential act (it would mint a way in with no password). */
654
- export type AuthMethod = "password-now" | "sso-now" | "fresh-password" | "fresh-sso" | "session" | "cli-login" | "remote-token" | "lead-token";
655
+ /** "local-owner" (easy-start C1): the local owner of a laptop install, in an allowlisted act (local-owner.ts) — a person,
656
+ * never a credential act, and its approvals stand only while local mode stays on at their generation. */
657
+ export type AuthMethod = "password-now" | "sso-now" | "fresh-password" | "fresh-sso" | "session" | "cli-login" | "remote-token" | "lead-token" | "local-owner";
655
658
  /**
656
659
  * The local CLI's remembered login, marked for the one command that read it (operate.ts credentialsFrom): with
657
660
  * Passwordless on, a proof made with it records "cli-login" — never a typed password — and no credential act accepts it.
@@ -794,7 +797,10 @@ export declare function approve(store: Store, taskId: string, by: string, now: D
794
797
  /** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
795
798
  export declare const APPROVAL_PROOF = "approval proved";
796
799
  /** The proof row's detail: when the person was proved, the generation, and the exact digest they said yes to. */
797
- export declare const approvalProofDetail: (at: number, generation: number, digest: string) => string;
800
+ export declare const approvalProofDetail: (at: number, generation: number, digest: string, local?: {
801
+ generation: number;
802
+ transport: LocalTransport;
803
+ }) => string;
798
804
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
799
805
  export declare function approvalProofOf(store: Store, taskId: string, by: string, digest: string): {
800
806
  method: string;
@@ -1007,3 +1013,19 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
1007
1013
  /** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
1008
1014
  * only: freshnessFor decides whether it stands in, at the moment of use. */
1009
1015
  export declare function freshUntil(at: string): string | null;
1016
+ export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed";
1017
+ /**
1018
+ * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
1019
+ * preview (the task page's approve form and the chat review render no password field when it is ok), the chat
1020
+ * precheck (an empty field passes) and the seal itself (approve(), inside its transaction). Refused: local mode off or
1021
+ * another person; no plan; release or deploy terms; protected or two-person work; an escalated profile; work a
1022
+ * coordinator filed; a project whose checks are stored as unsandboxed (or a plan that sealed that). Plans a lead or
1023
+ * a scout proposed are allowed.
1024
+ */
1025
+ export declare function localApprovalEligible(store: Store, by: string, taskId: string): {
1026
+ ok: true;
1027
+ } | {
1028
+ ok: false;
1029
+ reason: LocalApprovalRefusal;
1030
+ message: string;
1031
+ };
package/dist/scope.js CHANGED
@@ -6,6 +6,7 @@ import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from ".
6
6
  import { validateScopeText } from "./task-text.js";
7
7
  import { contractChangesOf } from "./contract-changes.js";
8
8
  import { AsyncLocalStorage } from "node:async_hooks";
9
+ import { currentLocalOwner, localModeOn, localOwnerInFlight, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
9
10
  import { projectAuthority } from "./project-access.js";
10
11
  import { activeRemote } from "./remote-run.js";
11
12
  import { claimActor, currentActor } from "./actor.js";
@@ -824,7 +825,9 @@ export function authenticateAccount(store, by, secret) {
824
825
  const hash = account?.credentialHash ?? dummy;
825
826
  if (!hash.startsWith("scrypt$"))
826
827
  verifyCredential(dummy, secret);
827
- const matches = verifyCredential(hash, secret);
828
+ // v147: an account with no password set (a local-mode owner) has a random hash; the KDF still runs (same timing), and
829
+ // nothing it is given ever matches.
830
+ const matches = verifyCredential(hash, secret) && account !== null && account.passwordSet;
828
831
  if (account === null || !matches) {
829
832
  guard.failed(by, now, source, account !== null);
830
833
  return { ok: false, reason: "unknown" };
@@ -902,6 +905,16 @@ export function ssoNowAvailable(by, now = Date.now()) {
902
905
  */
903
906
  export function proveAccountNow(store, by, token, options = {}) {
904
907
  const now = Date.now();
908
+ // Easy-start C1: a local owner is decided FIRST, before Passwordless and freshness, whatever was typed. An allowlisted
909
+ // act of its own, still standing (re-read here, inside the caller's transaction), is "local-owner"; anything else —
910
+ // a credential act, a read, another person, local mode moved — proves nothing. It never upgrades to a typed password.
911
+ // A lead's token or a remote API token in that chain is never the local owner (it stays a machine's proof below).
912
+ const local = activeRemote() === null && currentActor()?.lead !== true ? currentLocalOwner() : null;
913
+ if (local !== null) {
914
+ if (options.credential === true || local.account !== by || local.act === "read" || !localOwnerInFlight(local) || !localOwnerStandsNow(store, local))
915
+ return { ok: false, reason: "not-an-approver" };
916
+ return { ok: true, method: "local-owner", at: now };
917
+ }
905
918
  const facts = freshFacts.getStore();
906
919
  if (facts != null && facts.actor === by && !facts.live())
907
920
  return { ok: false, reason: "not-an-approver" };
@@ -1072,6 +1085,16 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1072
1085
  // credential, not a person at a ceremony. Refused before any vote or proof row is written.
1073
1086
  if (!personProof(authenticated.method))
1074
1087
  return { ok: false, reason: "machine-proof" };
1088
+ // Easy-start C5: a local owner approves only as the approve-plan act, and only eligible work (one shared rule:
1089
+ // localApprovalEligible, read again here inside the seal's transaction).
1090
+ const local = authenticated.method === "local-owner" ? currentLocalOwner() : null;
1091
+ if (authenticated.method === "local-owner") {
1092
+ if (local === null || local.act !== "approve-plan")
1093
+ return { ok: false, reason: "policy", message: localOwnerRefusal(store.accountOf(by)?.passwordSet === true, "/").message };
1094
+ const eligible = localApprovalEligible(store, by, taskId);
1095
+ if (!eligible.ok)
1096
+ return { ok: false, reason: "policy", message: eligible.message };
1097
+ }
1075
1098
  const scope = store.getScope(taskId);
1076
1099
  if (scope === null)
1077
1100
  return { ok: false, reason: "no-scope" };
@@ -1121,7 +1144,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1121
1144
  // How the person was proved, beside every yes they give (a vote kept for protected work, or the seal): a password
1122
1145
  // typed now, a re-check now, or a remembered one — with when, the credential generation and the exact digest.
1123
1146
  const proved = () => store.recordAction({ at: now.toISOString(), actor: by, repo: store.lookupRef(taskId)?.repo ?? null, taskId, runId: null, action: APPROVAL_PROOF,
1124
- outcome: authenticated.method, source: "work", detail: approvalProofDetail(authenticated.at, store.accountOf(by)?.generation ?? 0, scope.digest) });
1147
+ outcome: authenticated.method, source: "work", detail: approvalProofDetail(authenticated.at, store.accountOf(by)?.generation ?? 0, scope.digest, local === null ? undefined : { generation: local.localGeneration, transport: local.transport }) });
1125
1148
  if (gate.verdict === "vote") {
1126
1149
  // The same person again adds nothing: no new vote, no proof row (and the approval roads refund a re-check).
1127
1150
  if (gate.already)
@@ -1161,7 +1184,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1161
1184
  /** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
1162
1185
  export const APPROVAL_PROOF = "approval proved";
1163
1186
  /** The proof row's detail: when the person was proved, the generation, and the exact digest they said yes to. */
1164
- export const approvalProofDetail = (at, generation, digest) => `${new Date(at).toISOString()} · generation ${generation} · digest ${digest}`;
1187
+ export const approvalProofDetail = (at, generation, digest, local) => `${new Date(at).toISOString()} · generation ${generation}${local === undefined ? "" : ` · local ${local.generation} ${local.transport}`} · digest ${digest}`;
1165
1188
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
1166
1189
  export function approvalProofOf(store, taskId, by, digest) {
1167
1190
  const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
@@ -1398,6 +1421,10 @@ export function proofDetailOf(detail) {
1398
1421
  }
1399
1422
  }
1400
1423
  const parts = detail.split(" · ");
1424
+ // Easy-start C5: a local owner's proof names its local generation and transport between the two (approvalProofDetail).
1425
+ const local = parts.length === 4 ? parts.splice(2, 1)[0] : null;
1426
+ if (local !== null && !/^local \d+ (browser|terminal)$/.test(local))
1427
+ return null;
1401
1428
  if (parts.length !== 3 || !parts[2].startsWith("digest ") || !/^generation \d+$/.test(parts[1]) || Number.isNaN(Date.parse(parts[0])))
1402
1429
  return null;
1403
1430
  return { at: parts[0], digest: parts[2].slice("digest ".length) };
@@ -1616,3 +1643,47 @@ export function freshUntil(at) {
1616
1643
  const ms = Date.parse(at);
1617
1644
  return Number.isFinite(ms) ? new Date(ms + FRESH_WINDOW_MS).toISOString() : null;
1618
1645
  }
1646
+ const LOCAL_APPROVAL_WORDS = {
1647
+ "local-mode-off": "Local mode is off: approve with your password.",
1648
+ "not-owner": "Only this computer's owner approves here without a password.",
1649
+ "no-scope": "This task has no plan to approve yet.",
1650
+ release: "Releases and deploys need your password.",
1651
+ protected: "Protected work needs your password and a second approver.",
1652
+ escalated: "Work with wider permissions needs your password.",
1653
+ coordinator: "Work a coordinator filed needs your password.",
1654
+ unsandboxed: "This project's checks run without a sandbox, so approving needs your password.",
1655
+ };
1656
+ /**
1657
+ * Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
1658
+ * preview (the task page's approve form and the chat review render no password field when it is ok), the chat
1659
+ * precheck (an empty field passes) and the seal itself (approve(), inside its transaction). Refused: local mode off or
1660
+ * another person; no plan; release or deploy terms; protected or two-person work; an escalated profile; work a
1661
+ * coordinator filed; a project whose checks are stored as unsandboxed (or a plan that sealed that). Plans a lead or
1662
+ * a scout proposed are allowed.
1663
+ */
1664
+ export function localApprovalEligible(store, by, taskId) {
1665
+ const no = (reason) => ({ ok: false, reason, message: LOCAL_APPROVAL_WORDS[reason] });
1666
+ if (!localModeOn(store))
1667
+ return no("local-mode-off");
1668
+ const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
1669
+ if (owner !== by)
1670
+ return no("not-owner");
1671
+ const scope = store.getScope(taskId);
1672
+ if (scope === null)
1673
+ return no("no-scope");
1674
+ if (deployGateScope(scope) || releaseCheckScope(scope))
1675
+ return no("release");
1676
+ const gate = store.approvalGate(taskId, by, "person");
1677
+ if (gate.verdict !== "seal" || gate.protectedWork)
1678
+ return no("protected");
1679
+ if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
1680
+ return no("escalated");
1681
+ const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
1682
+ if (ref === undefined || ref["coordinator_cid"] != null)
1683
+ return no("coordinator");
1684
+ // The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
1685
+ const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
1686
+ if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
1687
+ return no("unsandboxed");
1688
+ return { ok: true };
1689
+ }
package/dist/serve.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { secretFolder } from "./tool-secret-store.js";
2
2
  import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
- import { syncProjectIdentities } from "./project-identity.js";
3
+ import { consoleFolderGate, syncProjectIdentities } from "./project-identity.js";
4
+ import { inFolderScope } from "./folder-gate.js";
4
5
  import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
5
6
  import { loadOrCreateVapidKeys } from "./push.js";
6
7
  import { checkoutPlan } from "./checkout-cleanup.js";
@@ -153,19 +154,22 @@ import { createPagesHandlers } from './server/pages.js';
153
154
  import { createPeopleHandlers } from './server/people-tokens.js';
154
155
  import { createSettingsHandlers } from './server/settings.js';
155
156
  import { requestContext } from "./server/request-context.js";
157
+ import { asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
158
+ import { defaultAgentOf, fillDefaultRouting } from "./model-choices.js";
156
159
  import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
157
- import { BODY_CAP, form, loginHref, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
160
+ import { BODY_CAP, form, loginHref, passwordSetNext, refusalNext, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
158
161
  import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, NOT_STARTED_SESSION, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
159
162
  import {} from "./server/render-pages.js";
160
- import { serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
163
+ import { localOwnerStepUps, serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
161
164
  import { NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, PersistentSessions, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, sessionWithinLifetime, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
162
165
  import { createTasksHandlers } from './server/tasks.js';
163
166
  import { KIT_BEHAVIOR_MARKUP, KIT_STANDALONE_SCRIPT } from './server/kit-standalone.js';
164
167
  import { createWelcomeHandlers } from './server/welcome.js';
168
+ import { createBrowserKeys, KEY_FILL_SCRIPT, pagePathOf } from './server/mutation-key.js';
165
169
  import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
166
170
  import { sourceKey } from "./source-key.js";
167
171
  import { readSsoSettings } from "./sso-settings.js";
168
- import { LEAD_THREAD } from "./store.js";
172
+ import { LEAD_THREAD, localOwnerBinding } from "./store.js";
169
173
  import { waitingUpdate } from "./toolroll-update.js";
170
174
  import { PACKAGE_VERSION } from "./version.js";
171
175
  import { loadConsoleUrl } from "./webhooks.js";
@@ -210,6 +214,7 @@ export function createDecisionServer(options) {
210
214
  get signInBudget() { return signInBudget; },
211
215
  get passwordAllowed() { return passwordAllowed; },
212
216
  get sessions() { return sessions; },
217
+ get browserKeys() { return browserKeys; },
213
218
  });
214
219
  const { store, evidenceRoot } = options;
215
220
  let coding = options.codingWorkspace ?? null;
@@ -362,17 +367,13 @@ export function createDecisionServer(options) {
362
367
  const linkKey = (code) => createHash("sha256").update(code).digest("hex");
363
368
  function mintSignInLink(account) {
364
369
  const known = store.accountOf(account);
365
- if (known === null || known.role !== "approver" || known.revokedAt !== null)
370
+ // A full link is for an account with a password: a local owner without one gets only mintLocalLink's.
371
+ if (known === null || known.role !== "approver" || known.revokedAt !== null || !known.passwordSet)
366
372
  return null;
367
- for (const [key, one] of signInLinks)
368
- if (one.expires < Date.now())
369
- signInLinks.delete(key);
370
- while (signInLinks.size >= 20)
371
- signInLinks.delete(signInLinks.keys().next().value);
372
- const code = randomBytes(32).toString("base64url");
373
- signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
374
- return `${SIGN_IN_LINK_PATH}${code}`;
373
+ return holdSignInLink({ kind: "full", account, expires: Date.now() + SIGN_IN_LINK_MS });
375
374
  }
375
+ /** v147: this console start's key epoch (easy-start C2): the browser-key wave binds each session's mutation key to it. */
376
+ const keyEpoch = options.keyEpoch ?? randomBytes(16).toString("hex");
376
377
  /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
377
378
  * any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. */
378
379
  const fromThisComputer = (request) => {
@@ -670,7 +671,7 @@ export function createDecisionServer(options) {
670
671
  instrumentRequest(store.telemetry, request, response);
671
672
  // Every password checked while answering counts against where this request came from (sign-in-guard.ts).
672
673
  // A GET or HEAD only reads (docs/plans/get-side-effects.md): store reads that would tidy up as they go don't.
673
- const answer = () => handle(request, response);
674
+ const answer = () => consoleFolders(options, identities, () => handle(request, response));
674
675
  void withPasswordSource(joinSourceOf(request), () => request.method === "GET" || request.method === "HEAD" ? asReadOnlyRequest(answer) : answer()).catch(error => {
675
676
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query. The reference
676
677
  // is what a person's error page shows, so the log line can be found from it.
@@ -753,6 +754,20 @@ export function createDecisionServer(options) {
753
754
  const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
754
755
  return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
755
756
  };
757
+ /** Easy-start C2: a protected loopback install (bound to loopback only, no --allow-host, no public URL, no tailnet
758
+ * names) has one browser host, and there every browser mutation also presents the session's key (mutation-key.ts). */
759
+ const browserKeys = createBrowserKeys({
760
+ enabled: options.browserKey === true,
761
+ epoch: keyEpoch,
762
+ protectedHost: () => {
763
+ const address = server.address(), port = servedPort();
764
+ if (typeof address !== "object" || address === null || port === null)
765
+ return null;
766
+ if ((options.allowedHosts ?? []).length > 0 || publicOrigin !== null || tailnet.length > 0)
767
+ return null;
768
+ return address.address === "127.0.0.1" ? `127.0.0.1:${port}` : address.address === "::1" ? `[::1]:${port}` : null;
769
+ },
770
+ });
756
771
  /** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
757
772
  const consoleCommand = (change) => {
758
773
  const port = servedPort() ?? 4180;
@@ -823,6 +838,17 @@ export function createDecisionServer(options) {
823
838
  if (!allowedHost(request.headers.host)) {
824
839
  return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
825
840
  }
841
+ // Easy-start C2: a protected loopback install has one browser origin, whose storage holds the session's mutation key.
842
+ // A browser's page read at another loopback name (localhost, [::1]) moves there; machine callers and protocol
843
+ // adapters (an API token, MCP's own addresses) are left where they are.
844
+ const canonical = browserKeys.canonicalHost();
845
+ if (canonical !== null && request.headers.host !== canonical && (method === "GET" || method === "HEAD") && request.headers.authorization === undefined
846
+ && (edgeRoute === null || edgeRoute.domain === "people")) {
847
+ request.resume();
848
+ response.writeHead(308, { location: `http://${canonical}${hook.pathname}${hook.search}`, "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
849
+ response.end();
850
+ return;
851
+ }
826
852
  // A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
827
853
  // plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
828
854
  const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
@@ -914,6 +940,13 @@ export function createDecisionServer(options) {
914
940
  ...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
915
941
  workspaceRequest: url.searchParams.get('request'),
916
942
  timeline,
943
+ // Easy-start C2: every page that posts carries the key fill while the key is required; a session whose key is from
944
+ // an earlier start (or has none) is told to reconnect.
945
+ keyFill: browserKeys.required(),
946
+ // Easy start: a local owner with no password sees "Set a password to do this." where a step-up field would be.
947
+ ...(who.via === "cookie" && who.authority === "local-owner" && store.accountOf(who.name)?.passwordSet === false
948
+ ? { localStepUp: localOwnerRefusal(false, method === "GET" ? passwordSetNext(pagePathOf(url)) : "/").href } : {}),
949
+ reconnect: browserKeys.stale(who, method === "GET" ? safeReturn(pagePathOf(url)) : "/"),
917
950
  };
918
951
  response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
919
952
  if (method === "GET" || method === "POST")
@@ -931,6 +964,12 @@ export function createDecisionServer(options) {
931
964
  return restricted()
932
965
  ? refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects")
933
966
  : refuse(response, who, 404, "There's no page at this address.", "/chat");
967
+ // Easy-start C1: a local owner's session reaches only the rows marked for it (route-table.ts `local`), decided
968
+ // first, before the route's policy reads anything and before any handler runs, GET and POST alike.
969
+ if (who.via === "cookie" && who.authority !== "full" && (route.local !== "allow" || localPrincipalOf(who, route) === null)) {
970
+ request.resume();
971
+ return refuseLocalOwner(response, who, method, url, request.headers.accept);
972
+ }
934
973
  const policy = evaluateRoutePolicy(route, { caller: who.via, capability: who.via === "bearer" && who.principal !== undefined ? who.principal.scope : who.role === "approver" ? "act" : "read", viewer: who.role === "viewer", token: who.via === "bearer" && who.principal !== undefined }, source => projectRequestAllowed(route, source, url, who, request, response, body));
935
974
  if (!policy.ok) {
936
975
  if (policy.reason === "project")
@@ -983,14 +1022,20 @@ export function createDecisionServer(options) {
983
1022
  }
984
1023
  return handlePost(route, url, who, request, response, body);
985
1024
  };
1025
+ // Easy-start C1: an allowed row runs as the local owner, in the act the row is (execute refuses every other): the
1026
+ // prover answers "local-owner" only for that act, and every write it makes re-proves it still stands.
1027
+ const local = who.via === "cookie" && who.authority !== "full" && matchedRoute?.stage === "console" && matchedRoute.local === "allow" ? localPrincipalOf(who, matchedRoute) : null;
1028
+ if (local !== null)
1029
+ response.once("close", () => { local.request.ended = true; });
1030
+ const run = () => local === null ? execute() : asLocalOwner(local, execute);
986
1031
  if (method === "GET")
987
- return projectAuthority.run({ actor: who.name, repo: target.repo }, execute);
1032
+ return projectAuthority.run({ actor: who.name, repo: target.repo }, run);
988
1033
  // Request acceptance is separate from durable work completion. Never
989
1034
  // persist a body, query string, token, password, or arbitrary URL.
990
1035
  const entry = { at: clock().toISOString(), actor: who.name, ...target, source: "request", ...(who.via === "bearer" && who.token !== undefined ? { detail: `API token: ${who.token}` } : {}) };
991
1036
  store.recordAction({ ...entry, outcome: "requested" });
992
1037
  try {
993
- await projectAuthority.run({ actor: who.name, repo: target.repo }, execute);
1038
+ await projectAuthority.run({ actor: who.name, repo: target.repo }, run);
994
1039
  const createdTask = requestContext.getStore()?.createdTask;
995
1040
  const placement = createdTask === undefined ? null : store.lookupRef(createdTask);
996
1041
  store.recordAction({ ...entry, ...(placement === null ? {} : { repo: placement.repo, taskId: placement.externalId }), at: clock().toISOString(), outcome: response.statusCode >= 500 ? "error" : response.statusCode >= 400 ? "refused" : "accepted" });
@@ -1194,8 +1239,15 @@ export function createDecisionServer(options) {
1194
1239
  if (provider === null)
1195
1240
  return;
1196
1241
  const now = clock();
1197
- store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
1198
- store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
1242
+ store.transact(() => {
1243
+ store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
1244
+ store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
1245
+ // A laptop install (local mode on): the agent chosen here also starts the models, on its default preset, while
1246
+ // nothing is chosen yet, so the owner's first task plans without a password (model-choices.ts fillDefaultRouting).
1247
+ const agent = defaultAgentOf(provider);
1248
+ if (agent !== null && localModeOn(store))
1249
+ fillDefaultRouting(store, agent, "toolroll", { kind: "first-start" }, now);
1250
+ });
1199
1251
  }
1200
1252
  // Look once at start, so the lead is on by the time the browser opens.
1201
1253
  if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
@@ -1516,9 +1568,16 @@ export function createDecisionServer(options) {
1516
1568
  if (s.workspace?.view?.kind === "flow")
1517
1569
  s = { ...s, workspace: { ...s.workspace, view: { ...s.workspace.view, stepUp: { label: ssoFacts.label, fresh: ssoFacts.fresh, confirmHref: `/login/sso?reauth=1&return=${encodeURIComponent(back)}` } } } };
1518
1570
  }
1571
+ // Easy start: a local owner with no password has none to type: each step-up field says how to set one instead.
1572
+ const localStepUp = requestContext.getStore()?.localStepUp;
1573
+ if (localStepUp !== undefined) {
1574
+ s = { ...s, body: localOwnerStepUps(s.body, localStepUp), ...(s.workspace?.pageHtml == null ? {} : { workspace: { ...s.workspace, pageHtml: localOwnerStepUps(s.workspace.pageHtml, localStepUp) } }) };
1575
+ }
1519
1576
  // D5: team chat (the central team service) is deprecated; /chat?team=1 still opens this release, but nothing links to it.
1520
1577
  const sensitive = authored || s.forceSensitive === true;
1521
1578
  const requestFacts = requestContext.getStore();
1579
+ // Easy-start C2: while the browser mutation key is required, every page's one nonce'd script also fills the key.
1580
+ const keyFill = requestFacts?.keyFill === true ? KEY_FILL_SCRIPT : "";
1522
1581
  // The settings frame (settings-kit.ts): its breadcrumb, for the shell and the workspace alike.
1523
1582
  if (s.chrome !== undefined && s.chrome.crumbs === undefined) {
1524
1583
  const crumbs = settingsCrumbs(new URL(requestFacts?.returnTo ?? '/', 'http://standing-orders.local').pathname, s.title, htmlString(s.body));
@@ -1667,6 +1726,8 @@ export function createDecisionServer(options) {
1667
1726
  ...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
1668
1727
  ...(extras.phone === undefined ? {} : { phone: extras.phone }),
1669
1728
  ...(extras.home == null ? {} : { home: extras.home }),
1729
+ // Easy-start C2: this tab's key is from an earlier start (or it has none): one way to reconnect.
1730
+ ...(requestFacts.reconnect == null ? {} : { reconnect: requestFacts.reconnect }),
1670
1731
  };
1671
1732
  if (requestFacts.workspaceRead) {
1672
1733
  const validator = requestFacts.workspaceValidator;
@@ -1692,13 +1753,18 @@ export function createDecisionServer(options) {
1692
1753
  // A page with its own view keeps its script; the chat page runs the result script.
1693
1754
  const functional = conversation === null || extras.view ? (s.functional?.script ?? '') : RESULT_REVIEW_SCRIPT;
1694
1755
  const document = shell(s.title, s.body, { chrome: s.chrome, sensitive });
1695
- return page(response, status, browserWorkspaceDocument(document, workspace, nonce, functional + MOBILE_VIEWPORT_SCRIPT), nonce, true);
1756
+ return page(response, status, browserWorkspaceDocument(document, workspace, nonce, functional + MOBILE_VIEWPORT_SCRIPT + keyFill), nonce, true);
1696
1757
  }
1697
1758
  // A one-time secret on screen (a token, an invite link, a pairing code): the
1698
1759
  // page carries no script of any kind, so it leaves the workspace for a
1699
1760
  // focused page in the same look: the brand, the one card, and the way back.
1700
- if (s.forceSensitive === true)
1701
- return page(response, status, focusDocument(s.title, s.body));
1761
+ // Easy-start C2: but for the key fill when it has a form that posts (the fill reads nothing on the page).
1762
+ if (s.forceSensitive === true) {
1763
+ if (keyFill === "" || !/<form\b[^>]*method="post"/i.test(htmlString(s.body)))
1764
+ return page(response, status, focusDocument(s.title, s.body));
1765
+ const nonce = randomBytes(16).toString("base64");
1766
+ return page(response, status, focusDocument(s.title, s.body, { nonce, script: keyFill }), nonce);
1767
+ }
1702
1768
  const chromeLayer = !sensitive && s.chrome !== undefined;
1703
1769
  const functional = s.functional?.script ?? "";
1704
1770
  // Sensitive pages strip the palette and keys but keep the sidebar toggle.
@@ -1706,7 +1772,7 @@ export function createDecisionServer(options) {
1706
1772
  // Kit markup the workspace bundle would have wired (a composer, an overlay, a switch, an idea) gets the kit's own small
1707
1773
  // behaviour here (kit-standalone.ts); never on a sensitive page, which carries no chrome additions.
1708
1774
  const kitBehavior = chromeLayer && KIT_BEHAVIOR_MARKUP.test(htmlString(s.body)) ? KIT_STANDALONE_SCRIPT : "";
1709
- const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome);
1775
+ const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome) + keyFill;
1710
1776
  const nonce = script === "" ? undefined : randomBytes(16).toString("base64");
1711
1777
  const body = chromeLayer
1712
1778
  ? html `${s.body}\n${paletteTagCached(s.chrome?.project ?? null)}\n${KBD_HELP}`
@@ -1971,7 +2037,7 @@ export function createDecisionServer(options) {
1971
2037
  // and role were admitted by the route table.
1972
2038
  const denied = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
1973
2039
  if (denied !== null)
1974
- return refuse(response, who, denied.status, denied.message);
2040
+ return refuse(response, who, denied.status, denied.message, "/", denied.reconnect?.kind === "password" ? { label: "Reconnect", href: denied.reconnect.href } : undefined);
1975
2041
  // Standing can change after a token was minted: a viewer's act stays refused, cookie and bearer alike, except
1976
2042
  // the row's session-local acts.
1977
2043
  if (who.role === "viewer" && !route.viewer)
@@ -1986,7 +2052,9 @@ export function createDecisionServer(options) {
1986
2052
  // deterministic scan is idle without new meaningful work; shutdown drains it.
1987
2053
  let leadMaintenance = null;
1988
2054
  let leadClosing = false;
1989
- server.once('listening', () => {
2055
+ // Started in the console's folder scope (folder-gate.ts), so their own timers and the personal queue's kick (which
2056
+ // restores the context it was started in, never a request's) read folders as the console's work does.
2057
+ server.once('listening', () => consoleFolders(options, identities, () => {
1990
2058
  team.start();
1991
2059
  personalQueue.start();
1992
2060
  leadMaintenance = startMaintenance({ intervalMs: 5_000, shouldStop: () => leadClosing,
@@ -1996,7 +2064,7 @@ export function createDecisionServer(options) {
1996
2064
  // Pending delivery is durable and is read again on the next pass; a failure is said where follow status is read.
1997
2065
  onError: error => recordLeadFollowFailure(store, error, clock()),
1998
2066
  });
1999
- });
2067
+ }));
2000
2068
  // Background checks (docs/plans/get-side-effects.md): what pages show about this computer's agent sign-ins and the
2001
2069
  // services Toolroll uses is asked here, on a timer, and a page reads the last answer. A page visit never starts a
2002
2070
  // process or a network call, and never acts on what a check finds: lifting a sign-in pause and the lead-by-default
@@ -2011,7 +2079,7 @@ export function createDecisionServer(options) {
2011
2079
  }
2012
2080
  catch { /* a page says secrets can't be read; the next action reports why */ }
2013
2081
  backgroundChecks = startMaintenance({ intervalMs: options.backgroundChecksMs ?? BACKGROUND_CHECKS_MS, shouldStop: () => leadClosing,
2014
- run: async () => {
2082
+ run: () => consoleFolders(options, identities, async () => {
2015
2083
  const now = Date.now();
2016
2084
  const waits = [];
2017
2085
  if (now - agentsCheckedAt >= (options.backgroundChecksMs ?? LOCAL_AGENTS_EVERY_MS)) {
@@ -2109,7 +2177,7 @@ export function createDecisionServer(options) {
2109
2177
  catch { /* shown as last saved */ }
2110
2178
  }
2111
2179
  await Promise.all(waits);
2112
- },
2180
+ }),
2113
2181
  onError: () => { }, });
2114
2182
  void backgroundChecks.runNow();
2115
2183
  });
@@ -2303,6 +2371,8 @@ export function createDecisionServer(options) {
2303
2371
  get firstTasks() { return firstTasks; },
2304
2372
  get authenticateCredential() { return authenticateCredential; },
2305
2373
  get identities() { return identities; },
2374
+ get keyEpoch() { return keyEpoch; },
2375
+ get browserKeys() { return browserKeys; },
2306
2376
  };
2307
2377
  const tasksHandlers = createTasksHandlers(handlerRuntime);
2308
2378
  const pagesHandlers = createPagesHandlers(handlerRuntime);
@@ -2312,6 +2382,7 @@ export function createDecisionServer(options) {
2312
2382
  const peopleHandlers = createPeopleHandlers(handlerRuntime);
2313
2383
  const remoteRuntime = {
2314
2384
  get identify() { return identify; },
2385
+ get browserKeys() { return browserKeys; },
2315
2386
  get freshFactsOf() { return freshFactsOf; },
2316
2387
  get store() { return store; },
2317
2388
  get admitPasswordSource() { return admitPasswordSource; },
@@ -2370,7 +2441,46 @@ export function createDecisionServer(options) {
2370
2441
  }
2371
2442
  return withEdgePolicy(route, () => registry.edge(route, { ...ctx, route }));
2372
2443
  }
2373
- return Object.assign(server, { mintSignInLink, closeCoding });
2444
+ /** Easy-start C1: the local owner a browser session acts as on an allowed row, bound to its session's saved row and
2445
+ * the generations it was made under; null when it no longer stands (refused like any other row). */
2446
+ function localPrincipalOf(who, route) {
2447
+ const sessionHash = sessions.hashOf(who.session);
2448
+ if (route.localAct === null || who.session.localGeneration === null || sessionHash === null)
2449
+ return null;
2450
+ const principal = { account: who.name, localGeneration: who.session.localGeneration, accountGeneration: who.session.generation,
2451
+ transport: "browser", act: route.localAct, sessionHash, request: { ended: false } };
2452
+ return localOwnerStandsNow(store, principal) ? principal : null;
2453
+ }
2454
+ /** What a local owner hears on a row not marked for it: set a first password, or sign in with the one set. */
2455
+ function refuseLocalOwner(response, who, method, url, accept) {
2456
+ // The page it was refused on, built here from the request (never from a header): a form's nearest page above it.
2457
+ const words = localOwnerRefusal(store.accountOf(who.name)?.passwordSet === true, refusalNext(method, url));
2458
+ // Which refusal this was, for the shell (it offers the same way on) and the route inventory's rule table.
2459
+ response.setHeader("x-toolroll-refused", "local-owner");
2460
+ if (String(accept ?? "").includes("application/json"))
2461
+ return respond(response, 403, "application/json", JSON.stringify({ ok: false, said: words.message, href: words.href }));
2462
+ return refuse(response, who, 403, words.message, words.href, { label: words.action, href: words.href });
2463
+ }
2464
+ // Defined last, so the reviewed functions above keep their places (password-freshness.test.ts).
2465
+ function holdSignInLink(link) {
2466
+ for (const [key, one] of signInLinks)
2467
+ if (one.expires < Date.now())
2468
+ signInLinks.delete(key);
2469
+ while (signInLinks.size >= 20)
2470
+ signInLinks.delete(signInLinks.keys().next().value);
2471
+ const code = randomBytes(32).toString("base64url");
2472
+ signInLinks.set(linkKey(code), link);
2473
+ return `${SIGN_IN_LINK_PATH}${code}`;
2474
+ }
2475
+ /** Easy-start C4: a local owner's one-time link. It names no account: local mode's own owner, read now, while local
2476
+ * mode is on and the owner is active. Redemption reads local mode again and issues only a local-owner session. */
2477
+ function mintLocalLink() {
2478
+ const binding = localOwnerBinding(store);
2479
+ if (binding === null)
2480
+ return null;
2481
+ return holdSignInLink({ kind: "local-owner", ...binding, expires: Date.now() + SIGN_IN_LINK_MS });
2482
+ }
2483
+ return Object.assign(server, { mintSignInLink, mintLocalLink, keyEpoch, closeCoding });
2374
2484
  }
2375
2485
  export { PAGE_CSS, pinnedTheme, SENSITIVE_INPUT } from "./server/chrome.js";
2376
2486
  export { redactedPath, SHUTDOWN_WAIT_MS } from "./server/http.js";
@@ -2415,3 +2525,22 @@ function startupCeiling(repos, roots, identities, canon) {
2415
2525
  configuredPaths,
2416
2526
  };
2417
2527
  }
2528
+ /** Each console's folder gate (folder-gate.ts), made once for its options. */
2529
+ const consoleGates = new WeakMap();
2530
+ /**
2531
+ * Runs the console's own work (a request, a background pass) in its folder scope: with identities resolved off its
2532
+ * thread (up and serve), it reads a person's folder synchronously only while that folder answered a look lately; its
2533
+ * own data (the database's folder, evidence, build checkouts) is read freely. Without identities (embedders, tests):
2534
+ * as before. Work that isn't the console's (a worker in the same process) never runs in it.
2535
+ */
2536
+ function consoleFolders(options, identities, work) {
2537
+ if (options.identities === undefined)
2538
+ return work();
2539
+ let gate = consoleGates.get(options);
2540
+ if (gate === undefined) {
2541
+ const databaseFile = options.store.databaseFile();
2542
+ gate = consoleFolderGate(identities, [options.configDir, options.evidenceRoot, options.poolRoot, databaseFile === null ? undefined : dirname(databaseFile)]);
2543
+ consoleGates.set(options, gate);
2544
+ }
2545
+ return inFolderScope(gate, work);
2546
+ }
@@ -1,4 +1,6 @@
1
1
  import { freshFacts, freshlyApprovable, freshnessFor, spendingOnlyOnSuccess, withFreshness } from "../scope.js";
2
+ import { CHAT_ACTION_LOCAL_ACTS, localOwnerRefusal } from "../local-owner.js";
3
+ import { localApprovalEligible } from "../scope.js";
2
4
  import { handlersOf } from './handler-registry.js';
3
5
  import { DEPRECATED_PAGE } from "../deprecations.js";
4
6
  /** chat handlers, moved without changing their route bodies. */
@@ -410,7 +412,12 @@ export function createChatHandlers(runtime) {
410
412
  const gate = store.approvalGate(task, who.name, 'person');
411
413
  return freshlyApprovable(store, task, scope, gate.verdict === 'seal' && !gate.protectedWork) ? new Date(fresh.at) : null;
412
414
  })();
413
- const passwordField = !CHAT_ACTIONS[action.operation].password ? '' : reauth(freshSince === null
415
+ // Easy-start C5: a laptop's local owner confirms its own acts without a password (an approval only when it is
416
+ // eligible: localApprovalEligible, the rule the seal applies again); anything else says how to get a password.
417
+ const reviewTask = typeof action.request['task'] === 'string' ? action.request['task'] : null;
418
+ const localOk = who.authority === 'local-owner' && CHAT_ACTION_LOCAL_ACTS[action.operation] !== null && (action.operation !== 'scope_approve' || (reviewTask !== null && localApprovalEligible(store, who.name, reviewTask).ok));
419
+ const localWords = who.authority === 'local-owner' && !localOk ? localOwnerRefusal(store.accountOf(who.name)?.passwordSet === true, '/chat') : null;
420
+ const passwordField = !CHAT_ACTIONS[action.operation].password || localOk ? '' : localWords !== null ? html `<p class="approval-password-note">${localWords.message} <a href="${localWords.href}">${localWords.action}</a></p>` : reauth(freshSince === null
414
421
  ? html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`
415
422
  : html `<label>Your password<input type="password" name="token" autocomplete="current-password"></label><p class="approval-password-note" data-approval-fresh>Your password from ${whenTime(freshSince.toISOString())} is remembered on this computer — leave this blank to approve with it, or type it again.</p>`);
416
423
  const content = html `<section class="shared-action"><h1>${action.title}</h1>${evidenceLink}${terms}${postForm(`/chat/proposal/${id}/confirm`, html `<label class="arm"><input type="checkbox" name="confirm" value="yes" required>I confirm this exact action</label>${passwordField}<button>${CHAT_ACTIONS[action.operation].label}</button>`, { returnTo: '/chat', hidden: { nonce: review.nonce } })}</section>`;
@@ -781,7 +788,7 @@ export function createChatHandlers(runtime) {
781
788
  }
782
789
  // A scope approval's re-check is spent only if the whole confirmation commits (one transaction: the proposal's
783
790
  // settlement included). Any other action spends it whatever comes of it (its effects aren't all in the database).
784
- const confirmIt = () => confirmLeadProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
791
+ const confirmIt = () => confirmLeadProposal(store, principal, id, now, { authority: who.authority, chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
785
792
  const proposed = store.getLeadProposal(id);
786
793
  const approving = proposed?.kind === "action" && sharedActionPayload(proposed.payload)?.operation === "scope_approve";
787
794
  const outcome = approving ? spendingOnlyOnSuccess(confirmIt, result => !result.ok || ("already" in result && result.already === true)) : confirmIt();
@@ -847,7 +854,7 @@ export function createChatHandlers(runtime) {
847
854
  dismissCoordinatorProposal(store, principal, id, now);
848
855
  return redirect(response, back);
849
856
  }
850
- const outcome = confirmCoordinatorProposal(store, principal, id, now, { confirm: body.get("confirm") === "yes", via: "web" });
857
+ const outcome = confirmCoordinatorProposal(store, principal, id, now, { authority: who.authority, confirm: body.get("confirm") === "yes", via: "web" });
851
858
  if (!outcome.ok && (outcome.reason === "not-yours" || outcome.reason === "standing")) {
852
859
  return refuse(response, who, outcome.reason === "standing" ? 403 : 404, outcome.said, back);
853
860
  }
@@ -21,7 +21,12 @@ export declare function isOverdue(decision: Decision, now: Date): boolean;
21
21
  * a way back — the error path is the one place a console must not stop
22
22
  * being a console. Bearer callers still get plain text; they parse, not read.
23
23
  */
24
- export declare function refuse(response: ServerResponse, who: Who | null, status: number, message: string, backHref?: string): void;
24
+ export declare function refuse(response: ServerResponse, who: Who | null, status: number, message: string, backHref?: string,
25
+ /** The one action that fixes it, in place of "Go back" (easy-start C2: Reconnect). */
26
+ act?: {
27
+ label: string;
28
+ href: string;
29
+ }): void;
25
30
  /** The demo's one-line promise, on every page. */
26
31
  export declare const DEMO_BANNER = "Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run npx toolroll up in its folder.";
27
32
  /** The demo notice on a phone: one line. */
@@ -233,7 +238,10 @@ export declare function personChip(name: string): Html;
233
238
  /** The brand, as the workspace sidebar shows it. */
234
239
  export declare const BRAND_HTML: Html;
235
240
  /** A page on its own, in the workspace's look but with no script: the brand, then the page. */
236
- export declare function focusDocument(title: string, body: Html): Html;
241
+ export declare function focusDocument(title: string, body: Html, live?: {
242
+ nonce: string;
243
+ script: string;
244
+ }): Html;
237
245
  export declare function chatMoney(microusd: number | null): string;
238
246
  export declare function relativeAge(iso: string, now: Date): string;
239
247
  /** The project chip every all-scope row wears: null is UNPLACED, said so. */