toolroll 0.9.89 → 0.9.91

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 (138) hide show
  1. package/CHANGELOG.md +63 -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 +32 -32
  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-delivery.js +2 -0
  18. package/dist/chat-task-actions.d.ts +1 -0
  19. package/dist/check-access.d.ts +3 -0
  20. package/dist/check-access.js +4 -1
  21. package/dist/cli-help.d.ts +1 -1
  22. package/dist/cli-help.js +6 -2
  23. package/dist/cli.d.ts +6 -0
  24. package/dist/cli.js +23 -0
  25. package/dist/coding-ui.js +4 -2
  26. package/dist/connect-cli.js +2 -1
  27. package/dist/console-instance.d.ts +34 -1
  28. package/dist/console-instance.js +124 -15
  29. package/dist/contracts/cli.d.ts +72 -0
  30. package/dist/contracts/cli.js +13 -0
  31. package/dist/contracts/console-api.d.ts +9 -2
  32. package/dist/contracts/console-api.js +9 -2
  33. package/dist/desktop-host.js +8 -3
  34. package/dist/desktop-update.js +2 -1
  35. package/dist/dispatch.d.ts +3 -0
  36. package/dist/dispatch.js +22 -2
  37. package/dist/easy-start-args.d.ts +13 -0
  38. package/dist/easy-start-args.js +24 -0
  39. package/dist/easy-start.d.ts +77 -0
  40. package/dist/easy-start.js +321 -0
  41. package/dist/first-run.d.ts +77 -0
  42. package/dist/first-run.js +45 -0
  43. package/dist/guides.js +1 -1
  44. package/dist/invoke.js +11 -6
  45. package/dist/laptop-background.d.ts +171 -0
  46. package/dist/laptop-background.js +550 -0
  47. package/dist/laptop-service.d.ts +111 -0
  48. package/dist/laptop-service.js +268 -0
  49. package/dist/lead-cli.js +2 -1
  50. package/dist/lead-doors.d.ts +6 -0
  51. package/dist/lead-doors.js +20 -2
  52. package/dist/live-records.d.ts +1 -1
  53. package/dist/live-records.js +3 -12
  54. package/dist/local-owner.d.ts +124 -0
  55. package/dist/local-owner.js +212 -0
  56. package/dist/login-secrets.d.ts +60 -0
  57. package/dist/login-secrets.js +195 -0
  58. package/dist/mcp-oauth.d.ts +4 -0
  59. package/dist/mcp-oauth.js +13 -7
  60. package/dist/model-choices.d.ts +43 -0
  61. package/dist/model-choices.js +80 -0
  62. package/dist/operate.d.ts +46 -1
  63. package/dist/operate.js +461 -40
  64. package/dist/person-proof.js +3 -0
  65. package/dist/project-knowledge.d.ts +7 -7
  66. package/dist/quick-start.d.ts +3 -1
  67. package/dist/quick-start.js +6 -1
  68. package/dist/release-adapters.d.ts +2 -0
  69. package/dist/release-adapters.js +30 -1
  70. package/dist/release-cli.js +2 -1
  71. package/dist/runner-process.d.ts +9 -0
  72. package/dist/runner-process.js +18 -0
  73. package/dist/sample-project.d.ts +31 -0
  74. package/dist/sample-project.js +141 -0
  75. package/dist/scope.d.ts +24 -2
  76. package/dist/scope.js +74 -3
  77. package/dist/serve.js +129 -22
  78. package/dist/server/chat.js +10 -3
  79. package/dist/server/chrome.d.ts +10 -2
  80. package/dist/server/chrome.js +7 -5
  81. package/dist/server/console.css +10 -0
  82. package/dist/server/guards.d.ts +3 -0
  83. package/dist/server/guards.js +22 -2
  84. package/dist/server/http.d.ts +22 -0
  85. package/dist/server/http.js +58 -0
  86. package/dist/server/live.js +4 -14
  87. package/dist/server/mutation-key.d.ts +70 -0
  88. package/dist/server/mutation-key.js +142 -0
  89. package/dist/server/pages.js +10 -7
  90. package/dist/server/people-tokens.js +240 -20
  91. package/dist/server/remote.d.ts +2 -0
  92. package/dist/server/remote.js +19 -4
  93. package/dist/server/render-new-task.js +5 -1
  94. package/dist/server/render-pages.d.ts +2 -1
  95. package/dist/server/render-pages.js +1 -1
  96. package/dist/server/render-people.d.ts +36 -0
  97. package/dist/server/render-people.js +65 -1
  98. package/dist/server/render-projects.js +4 -2
  99. package/dist/server/render-tasks.d.ts +12 -0
  100. package/dist/server/render-tasks.js +12 -4
  101. package/dist/server/request-context.d.ts +6 -0
  102. package/dist/server/route-table.d.ts +16 -0
  103. package/dist/server/route-table.js +121 -0
  104. package/dist/server/runtime.d.ts +6 -4
  105. package/dist/server/session.d.ts +31 -1
  106. package/dist/server/session.js +12 -2
  107. package/dist/server/settings.js +63 -8
  108. package/dist/server/task-views.js +10 -1
  109. package/dist/server/tasks.d.ts +10 -0
  110. package/dist/server/tasks.js +59 -9
  111. package/dist/server/terminals.js +15 -2
  112. package/dist/server/welcome.d.ts +22 -16
  113. package/dist/server/welcome.js +150 -52
  114. package/dist/settings-groups-ui.d.ts +7 -1
  115. package/dist/settings-groups-ui.js +23 -2
  116. package/dist/settings-proofs.js +1 -0
  117. package/dist/setup-guide.d.ts +2 -2
  118. package/dist/store.d.ts +124 -1
  119. package/dist/store.js +245 -12
  120. package/dist/surface.d.ts +10 -0
  121. package/dist/surface.js +40 -1
  122. package/dist/task-text.d.ts +6 -0
  123. package/dist/task-text.js +17 -0
  124. package/dist/telegram-lead.js +2 -0
  125. package/dist/terminal-text.d.ts +11 -0
  126. package/dist/terminal-text.js +26 -0
  127. package/dist/toolroll-update.d.ts +8 -1
  128. package/dist/toolroll-update.js +151 -4
  129. package/dist/tui-contract.d.ts +118 -0
  130. package/dist/tui-contract.js +9 -0
  131. package/dist/tui-source.d.ts +14 -0
  132. package/dist/tui-source.js +182 -0
  133. package/dist/tui.js +129 -0
  134. package/dist/up-processes.d.ts +13 -1
  135. package/dist/up-processes.js +13 -3
  136. package/dist/welcome-facts.d.ts +18 -0
  137. package/dist/welcome-facts.js +79 -0
  138. package/package.json +4 -2
package/dist/serve.js CHANGED
@@ -154,19 +154,22 @@ import { createPagesHandlers } from './server/pages.js';
154
154
  import { createPeopleHandlers } from './server/people-tokens.js';
155
155
  import { createSettingsHandlers } from './server/settings.js';
156
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";
157
159
  import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
158
- 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";
159
161
  import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, NOT_STARTED_SESSION, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
160
162
  import {} from "./server/render-pages.js";
161
- import { serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
163
+ import { localOwnerStepUps, serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
162
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";
163
165
  import { createTasksHandlers } from './server/tasks.js';
164
166
  import { KIT_BEHAVIOR_MARKUP, KIT_STANDALONE_SCRIPT } from './server/kit-standalone.js';
165
167
  import { createWelcomeHandlers } from './server/welcome.js';
168
+ import { createBrowserKeys, KEY_FILL_SCRIPT, pagePathOf } from './server/mutation-key.js';
166
169
  import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
167
170
  import { sourceKey } from "./source-key.js";
168
171
  import { readSsoSettings } from "./sso-settings.js";
169
- import { LEAD_THREAD } from "./store.js";
172
+ import { LEAD_THREAD, localOwnerBinding } from "./store.js";
170
173
  import { waitingUpdate } from "./toolroll-update.js";
171
174
  import { PACKAGE_VERSION } from "./version.js";
172
175
  import { loadConsoleUrl } from "./webhooks.js";
@@ -211,6 +214,7 @@ export function createDecisionServer(options) {
211
214
  get signInBudget() { return signInBudget; },
212
215
  get passwordAllowed() { return passwordAllowed; },
213
216
  get sessions() { return sessions; },
217
+ get browserKeys() { return browserKeys; },
214
218
  });
215
219
  const { store, evidenceRoot } = options;
216
220
  let coding = options.codingWorkspace ?? null;
@@ -363,17 +367,13 @@ export function createDecisionServer(options) {
363
367
  const linkKey = (code) => createHash("sha256").update(code).digest("hex");
364
368
  function mintSignInLink(account) {
365
369
  const known = store.accountOf(account);
366
- 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)
367
372
  return null;
368
- for (const [key, one] of signInLinks)
369
- if (one.expires < Date.now())
370
- signInLinks.delete(key);
371
- while (signInLinks.size >= 20)
372
- signInLinks.delete(signInLinks.keys().next().value);
373
- const code = randomBytes(32).toString("base64url");
374
- signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
375
- return `${SIGN_IN_LINK_PATH}${code}`;
373
+ return holdSignInLink({ kind: "full", account, expires: Date.now() + SIGN_IN_LINK_MS });
376
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");
377
377
  /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
378
378
  * any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. */
379
379
  const fromThisComputer = (request) => {
@@ -754,6 +754,20 @@ export function createDecisionServer(options) {
754
754
  const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
755
755
  return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
756
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
+ });
757
771
  /** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
758
772
  const consoleCommand = (change) => {
759
773
  const port = servedPort() ?? 4180;
@@ -824,6 +838,17 @@ export function createDecisionServer(options) {
824
838
  if (!allowedHost(request.headers.host)) {
825
839
  return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
826
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
+ }
827
852
  // A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
828
853
  // plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
829
854
  const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
@@ -915,6 +940,13 @@ export function createDecisionServer(options) {
915
940
  ...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
916
941
  workspaceRequest: url.searchParams.get('request'),
917
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)) : "/"),
918
950
  };
919
951
  response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
920
952
  if (method === "GET" || method === "POST")
@@ -932,6 +964,12 @@ export function createDecisionServer(options) {
932
964
  return restricted()
933
965
  ? refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects")
934
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
+ }
935
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));
936
974
  if (!policy.ok) {
937
975
  if (policy.reason === "project")
@@ -984,14 +1022,20 @@ export function createDecisionServer(options) {
984
1022
  }
985
1023
  return handlePost(route, url, who, request, response, body);
986
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);
987
1031
  if (method === "GET")
988
- return projectAuthority.run({ actor: who.name, repo: target.repo }, execute);
1032
+ return projectAuthority.run({ actor: who.name, repo: target.repo }, run);
989
1033
  // Request acceptance is separate from durable work completion. Never
990
1034
  // persist a body, query string, token, password, or arbitrary URL.
991
1035
  const entry = { at: clock().toISOString(), actor: who.name, ...target, source: "request", ...(who.via === "bearer" && who.token !== undefined ? { detail: `API token: ${who.token}` } : {}) };
992
1036
  store.recordAction({ ...entry, outcome: "requested" });
993
1037
  try {
994
- await projectAuthority.run({ actor: who.name, repo: target.repo }, execute);
1038
+ await projectAuthority.run({ actor: who.name, repo: target.repo }, run);
995
1039
  const createdTask = requestContext.getStore()?.createdTask;
996
1040
  const placement = createdTask === undefined ? null : store.lookupRef(createdTask);
997
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" });
@@ -1195,8 +1239,15 @@ export function createDecisionServer(options) {
1195
1239
  if (provider === null)
1196
1240
  return;
1197
1241
  const now = clock();
1198
- store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
1199
- 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
+ });
1200
1251
  }
1201
1252
  // Look once at start, so the lead is on by the time the browser opens.
1202
1253
  if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
@@ -1517,9 +1568,16 @@ export function createDecisionServer(options) {
1517
1568
  if (s.workspace?.view?.kind === "flow")
1518
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)}` } } } };
1519
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
+ }
1520
1576
  // D5: team chat (the central team service) is deprecated; /chat?team=1 still opens this release, but nothing links to it.
1521
1577
  const sensitive = authored || s.forceSensitive === true;
1522
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 : "";
1523
1581
  // The settings frame (settings-kit.ts): its breadcrumb, for the shell and the workspace alike.
1524
1582
  if (s.chrome !== undefined && s.chrome.crumbs === undefined) {
1525
1583
  const crumbs = settingsCrumbs(new URL(requestFacts?.returnTo ?? '/', 'http://standing-orders.local').pathname, s.title, htmlString(s.body));
@@ -1668,6 +1726,8 @@ export function createDecisionServer(options) {
1668
1726
  ...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
1669
1727
  ...(extras.phone === undefined ? {} : { phone: extras.phone }),
1670
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 }),
1671
1731
  };
1672
1732
  if (requestFacts.workspaceRead) {
1673
1733
  const validator = requestFacts.workspaceValidator;
@@ -1693,13 +1753,18 @@ export function createDecisionServer(options) {
1693
1753
  // A page with its own view keeps its script; the chat page runs the result script.
1694
1754
  const functional = conversation === null || extras.view ? (s.functional?.script ?? '') : RESULT_REVIEW_SCRIPT;
1695
1755
  const document = shell(s.title, s.body, { chrome: s.chrome, sensitive });
1696
- 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);
1697
1757
  }
1698
1758
  // A one-time secret on screen (a token, an invite link, a pairing code): the
1699
1759
  // page carries no script of any kind, so it leaves the workspace for a
1700
1760
  // focused page in the same look: the brand, the one card, and the way back.
1701
- if (s.forceSensitive === true)
1702
- 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
+ }
1703
1768
  const chromeLayer = !sensitive && s.chrome !== undefined;
1704
1769
  const functional = s.functional?.script ?? "";
1705
1770
  // Sensitive pages strip the palette and keys but keep the sidebar toggle.
@@ -1707,7 +1772,7 @@ export function createDecisionServer(options) {
1707
1772
  // Kit markup the workspace bundle would have wired (a composer, an overlay, a switch, an idea) gets the kit's own small
1708
1773
  // behaviour here (kit-standalone.ts); never on a sensitive page, which carries no chrome additions.
1709
1774
  const kitBehavior = chromeLayer && KIT_BEHAVIOR_MARKUP.test(htmlString(s.body)) ? KIT_STANDALONE_SCRIPT : "";
1710
- const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome);
1775
+ const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome) + keyFill;
1711
1776
  const nonce = script === "" ? undefined : randomBytes(16).toString("base64");
1712
1777
  const body = chromeLayer
1713
1778
  ? html `${s.body}\n${paletteTagCached(s.chrome?.project ?? null)}\n${KBD_HELP}`
@@ -1972,7 +2037,7 @@ export function createDecisionServer(options) {
1972
2037
  // and role were admitted by the route table.
1973
2038
  const denied = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
1974
2039
  if (denied !== null)
1975
- 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);
1976
2041
  // Standing can change after a token was minted: a viewer's act stays refused, cookie and bearer alike, except
1977
2042
  // the row's session-local acts.
1978
2043
  if (who.role === "viewer" && !route.viewer)
@@ -2306,6 +2371,8 @@ export function createDecisionServer(options) {
2306
2371
  get firstTasks() { return firstTasks; },
2307
2372
  get authenticateCredential() { return authenticateCredential; },
2308
2373
  get identities() { return identities; },
2374
+ get keyEpoch() { return keyEpoch; },
2375
+ get browserKeys() { return browserKeys; },
2309
2376
  };
2310
2377
  const tasksHandlers = createTasksHandlers(handlerRuntime);
2311
2378
  const pagesHandlers = createPagesHandlers(handlerRuntime);
@@ -2315,6 +2382,7 @@ export function createDecisionServer(options) {
2315
2382
  const peopleHandlers = createPeopleHandlers(handlerRuntime);
2316
2383
  const remoteRuntime = {
2317
2384
  get identify() { return identify; },
2385
+ get browserKeys() { return browserKeys; },
2318
2386
  get freshFactsOf() { return freshFactsOf; },
2319
2387
  get store() { return store; },
2320
2388
  get admitPasswordSource() { return admitPasswordSource; },
@@ -2373,7 +2441,46 @@ export function createDecisionServer(options) {
2373
2441
  }
2374
2442
  return withEdgePolicy(route, () => registry.edge(route, { ...ctx, route }));
2375
2443
  }
2376
- 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 });
2377
2484
  }
2378
2485
  export { PAGE_CSS, pinnedTheme, SENSITIVE_INPUT } from "./server/chrome.js";
2379
2486
  export { redactedPath, SHUTDOWN_WAIT_MS } from "./server/http.js";
@@ -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. */
@@ -92,7 +92,9 @@ export function isOverdue(decision, now) {
92
92
  * a way back — the error path is the one place a console must not stop
93
93
  * being a console. Bearer callers still get plain text; they parse, not read.
94
94
  */
95
- export function refuse(response, who, status, message, backHref = "/") {
95
+ export function refuse(response, who, status, message, backHref = "/",
96
+ /** The one action that fixes it, in place of "Go back" (easy-start C2: Reconnect). */
97
+ act) {
96
98
  if (who === null || who.via === "bearer") {
97
99
  return respond(response, status, "text/plain; charset=utf-8", message);
98
100
  }
@@ -103,7 +105,7 @@ export function refuse(response, who, status, message, backHref = "/") {
103
105
  // The words are the caller's exactly (older callers wrote lower-case fragments; k-refusal capitalises the first letter).
104
106
  const body = html `<div class="k-refusal">${status === 404
105
107
  ? kitNotFound(path, generic ? {} : { title: message, text: "Home has what needs you; Tasks has every task." })
106
- : kitDoc(kitDocHeader({ title: "Request refused", back: [backHref, "Back"] }), kitNotice(message), kitActs(kitBtn("Go back", { href: backHref })))}</div>`;
108
+ : kitDoc(kitDocHeader({ title: "Request refused", back: [backHref, "Back"] }), kitNotice(message), kitActs(act === undefined ? kitBtn("Go back", { href: backHref }) : kitBtn(act.label, { href: act.href, variant: "primary" })))}</div>`;
107
109
  // A signed-in browser keeps the workspace around it: the same navigation as every page.
108
110
  const framed = requestContext.getStore()?.refusal;
109
111
  if (framed !== undefined && who.via === "cookie")
@@ -111,7 +113,7 @@ export function refuse(response, who, status, message, backHref = "/") {
111
113
  return page(response, status, shell(status === 404 ? "Not found" : "Request refused", outPage({
112
114
  brand: BRAND_HTML, title: status === 404 ? "Not found" : "Request refused",
113
115
  body: html `<div class="k-refusal">${kitNotice(status === 404 && generic ? `There\u2019s no page at ${path}.` : message)}</div>`,
114
- foot: html `<a class="k-link" href="${status === 404 ? "/" : backHref}">${status === 404 ? "Go to Home" : "Go back"}</a>`,
116
+ foot: act === undefined ? html `<a class="k-link" href="${status === 404 ? "/" : backHref}">${status === 404 ? "Go to Home" : "Go back"}</a>` : html `<a class="k-link" href="${act.href}">${act.label}</a>`,
115
117
  })));
116
118
  }
117
119
  /** The demo's one-line promise, on every page. */
@@ -652,8 +654,8 @@ export function personChip(name) {
652
654
  /** The brand, as the workspace sidebar shows it. */
653
655
  export const BRAND_HTML = html `<span class="so-brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>Toolroll`;
654
656
  /** A page on its own, in the workspace's look but with no script: the brand, then the page. */
655
- export function focusDocument(title, body) {
656
- return shell(title, html `<div class="focus-page"><a class="so-wordmark focus-brand" href="/chat">${BRAND_HTML}</a>${body}</div>`);
657
+ export function focusDocument(title, body, live) {
658
+ return shell(title, html `<div class="focus-page"><a class="so-wordmark focus-brand" href="/chat">${BRAND_HTML}</a>${body}</div>`, live === undefined ? {} : { live });
657
659
  }
658
660
  export function chatMoney(microusd) {
659
661
  return microusd === null ? "unknown" : `$${(microusd / 1_000_000).toFixed(2)}`;
@@ -2783,3 +2783,13 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
2783
2783
  .put-away-item { min-height: 44px; }
2784
2784
  .put-away-days button, .task-archived button, .work-row-status form.inline button { min-height: 44px; }
2785
2785
  }
2786
+ /* The welcome (/welcome): three steps from the kit's pieces. A form holding one button sits in its row of acts. */
2787
+ .welcome-step .k-note { margin: 0; }
2788
+ .welcome-rows { display: grid; }
2789
+ .welcome-rows .k-sl-t { display: block; overflow-wrap: anywhere; }
2790
+ .welcome-rows .k-mono { overflow-wrap: anywhere; }
2791
+ .welcome-form, .welcome .k-srec-a form { display: contents; }
2792
+ .welcome .k-btn { min-height: 0; }
2793
+ @media (max-width: 40rem) { .welcome form > .k-btn[type=submit] { width: auto; } }
2794
+ /* On a phone the step's primary lives in the dock: its copy in the step is hidden. */
2795
+ @media (max-width: 760px) { .welcome .k-btn.k-docked { display: none; } }
@@ -42,6 +42,8 @@ export interface GuardsRuntime {
42
42
  signInBudget: SourceBudget;
43
43
  passwordAllowed: (name: string) => boolean;
44
44
  sessions: PersistentSessions;
45
+ /** Easy-start C2: the browser mutation key's one check (server/mutation-key.ts). */
46
+ browserKeys: Pick<import("./mutation-key.js").BrowserKeys, "check">;
45
47
  }
46
48
  export declare function createSharedGuards(runtime: GuardsRuntime): {
47
49
  authenticateApprover: (who: Who, token: string, repo?: string | null) => ReturnType<typeof checkApproverPassword>;
@@ -49,6 +51,7 @@ export declare function createSharedGuards(runtime: GuardsRuntime): {
49
51
  authorizeMutation: (request: IncomingMessage, who: Who, body: FormView<FormFieldOf<"mutationGuard">>) => {
50
52
  status: number;
51
53
  message: string;
54
+ reconnect?: import("./mutation-key.js").Reconnect;
52
55
  } | null;
53
56
  projectOf: (who: Who, request: IncomingMessage) => string | null | undefined;
54
57
  resolveRouteProject: (source: RouteDeclaration['project'], url: URL, who: Who, request: IncomingMessage, body: URLSearchParams | null) => string | null;
@@ -9,7 +9,8 @@ import { REMOTE_MESSAGES, reproveRemote } from "../operate-remote.js";
9
9
  import { canonicalProject, rowVisible } from "../project.js";
10
10
  import { authenticateAccount, authenticateApprover as checkApproverPassword, credentialProof } from "../scope.js";
11
11
  import { SourceBudget } from "../sign-in-guard.js";
12
- import {} from "../store.js";
12
+ import { localModeOf } from "../store.js";
13
+ import { currentLocalOwner, localBindingStands, localOwnerStandsNow } from "../local-owner.js";
13
14
  import { requestContext } from "./request-context.js";
14
15
  import { refuse } from "./chrome.js";
15
16
  import { matchTaskPath } from "./http.js";
@@ -105,6 +106,19 @@ export function createSharedGuards(runtime) {
105
106
  if (body.get("csrf") !== who.session.csrf) {
106
107
  return { status: 403, message: "stale form — reload and try again" };
107
108
  }
109
+ // Easy-start C2: on a protected loopback install the CSRF token is rendered into pages any cookie holder can read,
110
+ // so a mutation also presents the session's key, which only this console's own origin holds.
111
+ const keyed = runtime.browserKeys.check(who, request, body.sent);
112
+ if (keyed !== null)
113
+ return keyed;
114
+ // Easy-start C1 (after the CSRF and key proofs, so a forged post learns nothing more): a local owner's session
115
+ // mutates only inside the console's admission of a row marked for it (which set the local owner this chain acts
116
+ // as), and only while that still stands; anywhere else it is refused.
117
+ if (who.authority !== "full") {
118
+ const local = currentLocalOwner();
119
+ if (local === null || local.account !== who.name || !localOwnerStandsNow(runtime.store, local))
120
+ return { status: 403, message: "Set a password to do this." };
121
+ }
108
122
  }
109
123
  return null;
110
124
  }
@@ -316,7 +330,7 @@ export function createSharedGuards(runtime) {
316
330
  if (match === null)
317
331
  return null;
318
332
  const session = lookupSession(match[1], touch);
319
- return session === null ? null : { name: session.name, via: "cookie", session, role: session.role };
333
+ return session === null ? null : { name: session.name, via: "cookie", session, role: session.role, authority: session.authority };
320
334
  }
321
335
  /**
322
336
  * Constant-time against the session table, small as it is — and every hit
@@ -350,6 +364,12 @@ export function createSharedGuards(runtime) {
350
364
  runtime.sessions.delete(id);
351
365
  return null;
352
366
  }
367
+ // v147: a local owner's session stands only while local mode is on, at the generation it was made under, for its
368
+ // owner. Off, moved or changed hands: the session is ended, never read as full.
369
+ if (session.authority !== "full" && !localBindingStands(localModeOf(runtime.store), { account: session.name, localGeneration: session.localGeneration })) {
370
+ runtime.sessions.delete(id);
371
+ return null;
372
+ }
353
373
  // Ended in another process (or from Sessions): the cached copy is dropped, never used.
354
374
  if (!runtime.sessions.stillSaved(id)) {
355
375
  Map.prototype.delete.call(runtime.sessions, id);
@@ -151,6 +151,12 @@ export type ServeOptions = {
151
151
  ssoFetch?: typeof fetch;
152
152
  /** Cockpit B1: this `up` console's instance secret (null: none — the instance route answers 404). */
153
153
  instanceSecret?: () => string | null;
154
+ /** v147 (easy-start C2): this console start's key epoch, minted once per start (absent: the server mints its own).
155
+ * A session's mutation key is bound to it; a key from another start is stale. */
156
+ keyEpoch?: string;
157
+ /** Easy-start C2: the browser mutation key. On (a real console start, operate.ts startConsole), a protected loopback
158
+ * install's browser sessions carry a key every mutation must present (server/mutation-key.ts). Off: unchanged. */
159
+ browserKey?: boolean;
154
160
  /** v137: whether this console is draining (its `up` is stopping): /healthz says so at once, and pages say Toolroll is restarting. */
155
161
  draining?: () => boolean;
156
162
  /** Cockpit E: how Finder is asked to reveal a proved checkout (tests pass their own; never a launch). True: shown. */
@@ -307,3 +313,19 @@ export declare function form(request: IncomingMessage, cap?: number): Promise<UR
307
313
  /** A refused chat setting (POST /chat/config): back on the lead's settings it is a ?problem=, which the page's section
308
314
  * shows in place with the edits kept; elsewhere it stays the chat's said line. */
309
315
  export declare function chatRefusal(back: string, words: string): string;
316
+ /** The longest page a refusal's "Set a password" link carries on to. */
317
+ export declare const PASSWORD_SET_NEXT_MAX = 512;
318
+ /**
319
+ * Where /password/set continues once the first password is set (easy-start C4): a page of this console a browser can
320
+ * open, as path and query only — one leading slash, never two, no backslash, no scheme or host (also once decoded),
321
+ * no control characters, at most PASSWORD_SET_NEXT_MAX characters, never the sign-in, sign-out, sign-up, join or
322
+ * password roads, never a region fetch, query keys that could carry a secret dropped. Anything else is home ("/").
323
+ */
324
+ export declare function passwordSetNext(raw: string | null | undefined): string;
325
+ /**
326
+ * The page a refused request was on, for its "Set a password" link: a page read is itself; a refused form is its own
327
+ * address when a browser can open it (a settings page posting to itself), else the nearest page above it
328
+ * (`/t/x/complete` → `/t/x`, `/flows/example` → `/flows`), since
329
+ * the console sends no Referer to name the page the form was on. Checked by passwordSetNext; else home.
330
+ */
331
+ export declare function refusalNext(method: string, url: URL): string;
@@ -19,6 +19,7 @@ import {} from "../toolroll-update.js";
19
19
  import { timingSafeEqual } from "node:crypto";
20
20
  import {} from "node:http";
21
21
  import { SIGN_IN_LINK_PATH } from "./session.js";
22
+ import { matchRoute } from "./route-table.js";
22
23
  import {} from "../folder-look.js";
23
24
  import {} from "../project-identity.js";
24
25
  import {} from "../learning-look.js";
@@ -257,3 +258,60 @@ export async function form(request, cap = BODY_CAP) {
257
258
  export function chatRefusal(back, words) {
258
259
  return back === "/settings/lead" ? `/settings/lead?problem=${encodeURIComponent(words)}` : chatReturnWithSaid(back, words);
259
260
  }
261
+ /** The longest page a refusal's "Set a password" link carries on to. */
262
+ export const PASSWORD_SET_NEXT_MAX = 512;
263
+ /**
264
+ * Where /password/set continues once the first password is set (easy-start C4): a page of this console a browser can
265
+ * open, as path and query only — one leading slash, never two, no backslash, no scheme or host (also once decoded),
266
+ * no control characters, at most PASSWORD_SET_NEXT_MAX characters, never the sign-in, sign-out, sign-up, join or
267
+ * password roads, never a region fetch, query keys that could carry a secret dropped. Anything else is home ("/").
268
+ */
269
+ export function passwordSetNext(raw) {
270
+ if (typeof raw !== "string" || raw === "" || raw.length > PASSWORD_SET_NEXT_MAX)
271
+ return "/";
272
+ if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\") || /[\u0000-\u001f\u007f]/.test(raw))
273
+ return "/";
274
+ let parsed;
275
+ try {
276
+ parsed = new URL(raw, "http://standing-orders.local");
277
+ }
278
+ catch {
279
+ return "/";
280
+ }
281
+ if (parsed.origin !== "http://standing-orders.local" || parsed.username !== "" || parsed.password !== "")
282
+ return "/";
283
+ let decoded;
284
+ try {
285
+ decoded = decodeURIComponent(parsed.pathname);
286
+ }
287
+ catch {
288
+ return "/";
289
+ }
290
+ if (/^\/\/|\\|[\u0000-\u001f\u007f]|^\/[a-z][a-z0-9+.-]*:/i.test(decoded) || /^\/(login|logout|signup|join|password)(\/|$)/.test(decoded))
291
+ return "/";
292
+ if (parsed.searchParams.has("fragment") || matchRoute("GET", parsed.pathname, "console") === null)
293
+ return "/";
294
+ for (const key of [...parsed.searchParams.keys()])
295
+ if (/token|password|secret|csrf|code|key|auth/i.test(key))
296
+ parsed.searchParams.delete(key);
297
+ const query = parsed.searchParams.toString();
298
+ const next = `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
299
+ return next.length > PASSWORD_SET_NEXT_MAX ? "/" : next;
300
+ }
301
+ /**
302
+ * The page a refused request was on, for its "Set a password" link: a page read is itself; a refused form is its own
303
+ * address when a browser can open it (a settings page posting to itself), else the nearest page above it
304
+ * (`/t/x/complete` → `/t/x`, `/flows/example` → `/flows`), since
305
+ * the console sends no Referer to name the page the form was on. Checked by passwordSetNext; else home.
306
+ */
307
+ export function refusalNext(method, url) {
308
+ if (method === "GET" || method === "HEAD")
309
+ return passwordSetNext(url.pathname + url.search);
310
+ const parts = url.pathname.split("/").filter(part => part !== "");
311
+ for (; parts.length > 0; parts.pop()) {
312
+ const page = passwordSetNext(`/${parts.join("/")}`);
313
+ if (page !== "/")
314
+ return page;
315
+ }
316
+ return "/";
317
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { boardRunOf, holdAgentView, releaseAgentView } from "../agent-board.js";
2
+ import { boardRunOf } from "../agent-board.js";
3
3
  import { agentRunFingerprint } from "../agents-board.js";
4
4
  import { createLiveRooms, LiveConnection } from "../live-bus.js";
5
5
  import { reproveRemote } from "../operate-remote.js";
@@ -269,20 +269,10 @@ export function createLiveHandlers(runtime, live) {
269
269
  if (run === null || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
270
270
  return json(response, 404, { error: "run" });
271
271
  // A repair is its build's own activity: its room is the build's.
272
- const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id), view = randomBytes(12).toString("hex");
273
- // The watch is a short lease in the database, renewed on every keep-alive and released when the page goes:
274
- // the process that sends chat pings reads it there (agent-board.ts watchingAgent).
275
- const watch = () => { try {
276
- holdAgentView(store, view, who.name, runId, clock());
277
- }
278
- catch { /* a missed renewal only lets a ping through */ } };
272
+ const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id);
273
+ // The watch lease (agent-board.ts watchingAgent) is held by the shell's keyed POST /agents/watch on each keep-alive
274
+ // and released on unload (easy-start N1): this stream only reads, so a stolen cookie can't hold one to quiet pings.
279
275
  joins.push(connection => {
280
- watch();
281
- connection.onKeepAlive(watch);
282
- connection.onClose(() => { try {
283
- releaseAgentView(store, view, clock());
284
- }
285
- catch { /* it lapses on its own */ } });
286
276
  agentRooms.join(runId, { name: who.name, room: name, connection,
287
277
  valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) });
288
278
  });