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
@@ -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
  });
@@ -0,0 +1,70 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Session, Who } from "./session.js";
3
+ /** The request header a fetch carries the key in. */
4
+ export declare const KEY_HEADER = "x-toolroll-key";
5
+ /** The form field a server-rendered form carries it in (not `key`: forms already use that for idempotency and API keys). */
6
+ export declare const KEY_FIELD = "toolroll-key";
7
+ /** A terminal socket offers `toolroll` (the protocol the server selects) and `toolroll.key.<key>` (never echoed back). */
8
+ export declare const KEY_PROTOCOL = "toolroll";
9
+ export declare const KEY_PROTOCOL_PREFIX = "toolroll.key.";
10
+ /** Where the browser keeps it: `{ epoch, key }` in the console origin's localStorage. */
11
+ export declare const KEY_STORAGE = "toolroll.key";
12
+ /** How a person gets a fresh key: their password again (a full session), or the terminal (a local owner). */
13
+ export type Reconnect = {
14
+ kind: "password";
15
+ href: string;
16
+ } | {
17
+ kind: "command";
18
+ command: string;
19
+ };
20
+ export type KeyRefusal = {
21
+ status: 403 | 409;
22
+ message: string;
23
+ reconnect: Reconnect;
24
+ };
25
+ export declare const RESTARTED = "Toolroll restarted \u2014 reconnect to make changes.";
26
+ export declare const RESTARTED_LOCAL = "Toolroll restarted \u2014 run `toolroll open` to reconnect.";
27
+ export declare const NOT_CONNECTED = "This tab isn't connected to Toolroll \u2014 reconnect to make changes.";
28
+ export declare const NOT_CONNECTED_LOCAL = "This tab isn't connected to Toolroll \u2014 run `toolroll open` to reconnect.";
29
+ /** The page a request reads, without the workspace read's own parameters: where a reconnect comes back to. */
30
+ export declare function pagePathOf(url: URL): string;
31
+ export declare const keyHashOf: (key: string) => string;
32
+ /** The one key a request presents (header, form field or socket protocol), or null for none, a malformed one, or two
33
+ * different ones: two keys in one request is a smuggling attempt, never a preference. */
34
+ export declare function presentedKey(request: IncomingMessage, body: {
35
+ getAll(name: string): string[];
36
+ } | null): string | null;
37
+ /** Constant-time: the presented key's hash against the session's. */
38
+ export declare function keyMatches(presented: string, hash: string | null): boolean;
39
+ export type BrowserKeys = {
40
+ /** Whether mutations need the key now (a protected loopback install, with the key turned on for this console). */
41
+ required(): boolean;
42
+ /** The console's one browser origin (http://127.0.0.1:<port>) while required, else null. */
43
+ canonicalHost(): string | null;
44
+ /** The one mutation check: null to proceed, else the refusal (with how to reconnect). Bearer callers pass. */
45
+ check(who: Who, request: IncomingMessage, body: {
46
+ getAll(name: string): string[];
47
+ } | null): KeyRefusal | null;
48
+ /** A browser session whose key is from an earlier console start (or none at all) while the key is required. */
49
+ stale(who: Who, back?: string): Reconnect | null;
50
+ /** Gives a session being made (or reconnected) a fresh key: its hash and this start's epoch go on the session, the key
51
+ * comes back to deliver once. Null while not required, or off the canonical origin (that origin's storage would hold it). */
52
+ issue(session: Pick<Session, "keyEpoch" | "keyHash">, request: IncomingMessage): string | null;
53
+ /** The no-store page that puts the key in this origin's storage and moves on to `to` (a same-site path). */
54
+ deliver(response: ServerResponse, key: string, to: string, cookies?: readonly string[]): void;
55
+ };
56
+ export declare function createBrowserKeys(deps: {
57
+ /** The console turned the key on (a real console start; tests and the demo leave it off unless they ask). */
58
+ enabled: boolean;
59
+ epoch: string;
60
+ /** On a protected loopback install (bound to loopback only, no --allow-host, no public URL, no tailnet names), read
61
+ * per request: the console's one browser host (`127.0.0.1:<port>`, or `[::1]:<port>` for an IPv6-only bind). Else null. */
62
+ protectedHost: () => string | null;
63
+ }): BrowserKeys;
64
+ /**
65
+ * Every page that can post, while the key is required: fills the key into each same-origin POST form's data (the
66
+ * `formdata` event fires for a native submit, requestSubmit(), submit() and `new FormData(form)` alike) and offers
67
+ * `window.soKeyHeaders(headers)` to the page's own fetches. It reads nothing on the page and sends the key nowhere but
68
+ * this origin. Without a stored key it does nothing, and the server answers "reconnect".
69
+ */
70
+ export declare const KEY_FILL_SCRIPT: string;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The browser mutation key (easy-start C2, amendment A1): the second factor every browser mutation carries on a
3
+ * protected loopback install, beside the session's CSRF token.
4
+ *
5
+ * Why: cookies are not isolated by port (RFC 6265 §8.5). Any server on another port of this computer receives the
6
+ * session cookie and can read every page, CSRF token included. So on a console that only this computer can reach, a
7
+ * mutation also needs a key that lives only in the browser's storage for the console's own origin
8
+ * (http://127.0.0.1:<port>): 32 random bytes per session, of which the server keeps only a hash and the console start
9
+ * (key epoch) it was issued under. The key is delivered once, in a response that cannot be fetched again (a one-time
10
+ * link, a password sign-in, an identity-provider finish, a password reconnect), by a no-store page whose one nonce'd
11
+ * script writes it to localStorage and moves on. It is never rendered into a page or JSON.
12
+ *
13
+ * The key is bound to the console start (N3): a process that held the port while Toolroll was stopped could read the
14
+ * saved key, so every restart mints a new epoch and every older key reads as "Toolroll restarted — reconnect".
15
+ *
16
+ * A protected loopback install: the console is bound to a loopback address only, with no --allow-host, no public URL
17
+ * and no tailnet names. Everywhere else nothing here applies.
18
+ */
19
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
20
+ import { html, htmlString, scriptElement } from "../html.js";
21
+ import { HANDOFF_STYLE } from "./http.js";
22
+ /** The request header a fetch carries the key in. */
23
+ export const KEY_HEADER = "x-toolroll-key";
24
+ /** The form field a server-rendered form carries it in (not `key`: forms already use that for idempotency and API keys). */
25
+ export const KEY_FIELD = "toolroll-key";
26
+ /** A terminal socket offers `toolroll` (the protocol the server selects) and `toolroll.key.<key>` (never echoed back). */
27
+ export const KEY_PROTOCOL = "toolroll";
28
+ export const KEY_PROTOCOL_PREFIX = "toolroll.key.";
29
+ /** Where the browser keeps it: `{ epoch, key }` in the console origin's localStorage. */
30
+ export const KEY_STORAGE = "toolroll.key";
31
+ const KEY_SHAPE = /^[0-9a-f]{64}$/;
32
+ export const RESTARTED = "Toolroll restarted — reconnect to make changes.";
33
+ export const RESTARTED_LOCAL = "Toolroll restarted — run `toolroll open` to reconnect.";
34
+ export const NOT_CONNECTED = "This tab isn't connected to Toolroll — reconnect to make changes.";
35
+ export const NOT_CONNECTED_LOCAL = "This tab isn't connected to Toolroll — run `toolroll open` to reconnect.";
36
+ /** The page a request reads, without the workspace read's own parameters: where a reconnect comes back to. */
37
+ export function pagePathOf(url) {
38
+ const page = new URL(url.pathname + url.search, "http://standing-orders.local");
39
+ page.searchParams.delete("format");
40
+ page.searchParams.delete("request");
41
+ return page.pathname + page.search;
42
+ }
43
+ export const keyHashOf = (key) => createHash("sha256").update(key, "utf8").digest("hex");
44
+ /** The one key a request presents (header, form field or socket protocol), or null for none, a malformed one, or two
45
+ * different ones: two keys in one request is a smuggling attempt, never a preference. */
46
+ export function presentedKey(request, body) {
47
+ const found = [];
48
+ const header = request.headers[KEY_HEADER];
49
+ if (Array.isArray(header))
50
+ return null;
51
+ if (typeof header === "string" && header !== "")
52
+ found.push(header);
53
+ if (body !== null)
54
+ found.push(...body.getAll(KEY_FIELD).filter(one => one !== ""));
55
+ const protocols = request.headers["sec-websocket-protocol"];
56
+ if (typeof protocols === "string") {
57
+ for (const one of protocols.split(",").map(part => part.trim()))
58
+ if (one.startsWith(KEY_PROTOCOL_PREFIX))
59
+ found.push(one.slice(KEY_PROTOCOL_PREFIX.length));
60
+ }
61
+ // The same key by two roads (a form's field and its fetch's header) is one key; two different keys is none.
62
+ const distinct = [...new Set(found)];
63
+ if (distinct.length !== 1)
64
+ return null;
65
+ const key = distinct[0];
66
+ return KEY_SHAPE.test(key) ? key : null;
67
+ }
68
+ /** Constant-time: the presented key's hash against the session's. */
69
+ export function keyMatches(presented, hash) {
70
+ if (hash === null || !KEY_SHAPE.test(hash))
71
+ return false;
72
+ const a = Buffer.from(keyHashOf(presented), "hex"), b = Buffer.from(hash, "hex");
73
+ return a.length === b.length && timingSafeEqual(a, b);
74
+ }
75
+ export function createBrowserKeys(deps) {
76
+ const canonicalHost = () => deps.enabled ? deps.protectedHost() : null;
77
+ const required = () => canonicalHost() !== null;
78
+ // A refused post comes back to Home (its own address may answer only posts); a page's notice, to that page.
79
+ const reconnectOf = (who, back = "/") => who.authority === "full"
80
+ ? { kind: "password", href: back === "/" ? "/login/rekey" : `/login/rekey?return=${encodeURIComponent(back)}` }
81
+ : { kind: "command", command: "toolroll open" };
82
+ const stale = (who, back) => {
83
+ if (!required() || who.via !== "cookie")
84
+ return null;
85
+ const session = who.session;
86
+ return session.keyEpoch !== deps.epoch || session.keyHash === null ? reconnectOf(who, back) : null;
87
+ };
88
+ return {
89
+ required,
90
+ canonicalHost,
91
+ check(who, request, body) {
92
+ if (!required() || who.via !== "cookie")
93
+ return null;
94
+ const local = who.authority !== "full";
95
+ const reconnect = reconnectOf(who);
96
+ if (stale(who) !== null)
97
+ return { status: 409, message: local ? RESTARTED_LOCAL : RESTARTED, reconnect };
98
+ const presented = presentedKey(request, body);
99
+ if (presented === null || !keyMatches(presented, who.session.keyHash))
100
+ return { status: 403, message: local ? NOT_CONNECTED_LOCAL : NOT_CONNECTED, reconnect };
101
+ return null;
102
+ },
103
+ stale,
104
+ issue(session, request) {
105
+ const canonical = canonicalHost();
106
+ if (canonical === null || request.headers.host !== canonical)
107
+ return null;
108
+ const key = randomBytes(32).toString("hex");
109
+ session.keyHash = keyHashOf(key);
110
+ session.keyEpoch = deps.epoch;
111
+ return key;
112
+ },
113
+ deliver(response, key, to, cookies = []) {
114
+ const nonce = randomBytes(16).toString("base64");
115
+ const target = /^\/(?![/\\])/.test(to) ? to : "/";
116
+ // JSON inside a script: `<` escaped so nothing in it can close the element.
117
+ const data = JSON.stringify({ storage: KEY_STORAGE, value: JSON.stringify({ epoch: deps.epoch, key }), to: target }).replace(/</g, "\\u003c");
118
+ response.writeHead(200, {
119
+ "content-type": "text/html; charset=utf-8", "cache-control": "no-store", pragma: "no-cache", "referrer-policy": "no-referrer",
120
+ "x-content-type-options": "nosniff", "x-frame-options": "DENY",
121
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
122
+ ...(cookies.length === 0 ? {} : { "set-cookie": [...cookies] }),
123
+ });
124
+ const script = `(function(){var d=${data};try{localStorage.setItem(d.storage,d.value)}catch(e){}location.replace(d.to)})();`;
125
+ response.end(htmlString(html `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Toolroll</title>${HANDOFF_STYLE}<p>Opening Toolroll… <a href="${target}">Continue</a></p>${scriptElement(script, { nonce })}</html>`));
126
+ },
127
+ };
128
+ }
129
+ /**
130
+ * Every page that can post, while the key is required: fills the key into each same-origin POST form's data (the
131
+ * `formdata` event fires for a native submit, requestSubmit(), submit() and `new FormData(form)` alike) and offers
132
+ * `window.soKeyHeaders(headers)` to the page's own fetches. It reads nothing on the page and sends the key nowhere but
133
+ * this origin. Without a stored key it does nothing, and the server answers "reconnect".
134
+ */
135
+ export const KEY_FILL_SCRIPT = `(function(){if(window.soKeyHeaders)return;` +
136
+ `function k(){try{var v=JSON.parse(localStorage.getItem(${JSON.stringify(KEY_STORAGE)})||"null");return v&&typeof v.key==="string"&&/^[0-9a-f]{64}$/.test(v.key)?v.key:null}catch(e){return null}}` +
137
+ `window.soKeyHeaders=function(h){h=h||{};var x=k();if(x)h[${JSON.stringify(KEY_HEADER)}]=x;return h};` +
138
+ // The form's own method and action through HTMLFormElement's getters: a control named "method" or "action" (the
139
+ // skill form's <select name="method">) shadows f.method / f.action, which would skip the key or misread the target.
140
+ `var P=HTMLFormElement.prototype,G=function(n){return Object.getOwnPropertyDescriptor(P,n).get};var M=G("method"),A=G("action");` +
141
+ `document.addEventListener("formdata",function(e){var f=e.target,m,a;try{m=M.call(f);a=A.call(f)}catch(x){return}if(String(m).toLowerCase()!=="post")return;` +
142
+ `try{if(new URL(a,location.href).origin!==location.origin)return}catch(x){return}var x=k();if(x)e.formData.set(${JSON.stringify(KEY_FIELD)},x)},true);})();`;
@@ -29,7 +29,6 @@ import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
29
29
  import { ledgerBody } from "../ledger-view.js";
30
30
  import { agentRowOf, agentsListOf, agentFactsFor, sealedDiffOf } from "../agents-board.js";
31
31
  import { computersOf, phasesOf, systemOf } from "./agents-computers.js";
32
- import { markAgentSeen } from "../agent-board.js";
33
32
  import { agentsLiveOf } from "../agents-live.js";
34
33
  import { readChildWindow } from "../live-children-read.js";
35
34
  import { leadFiledLinks } from "./agents-live-filed.js";
@@ -192,7 +191,9 @@ export function createPagesHandlers(runtime) {
192
191
  // One scope for the page and the chrome around it: the project its address names (proved by the router), else all.
193
192
  const chrome = chromeFor(project, "none");
194
193
  const projects = chrome.projects ?? [];
195
- const view = homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat: chrome.chat === true, canAnswer: who.role === "approver" });
194
+ const view = { ...homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat: chrome.chat === true, canAnswer: who.role === "approver" }),
195
+ // Until the first result: the welcome's three steps stay one link away.
196
+ setup: firstRunStepsNow(now) !== null ? "/welcome" : null };
196
197
  return sendScreen(response, 200, screen("Home", html `<div data-kit-page><h1>Home</h1><p>${view.say}</p><p><a href="/work">Tasks</a></p></div>`, { chrome, workspace: { view } }));
197
198
  }
198
199
  // Search (UI revamp wave B): the ⌘K menu's "See all results". The places come from the page; the tasks here are the
@@ -450,9 +451,8 @@ export function createPagesHandlers(runtime) {
450
451
  const run = store.getRun(Number(asked));
451
452
  const facts = run === null || !runVisible(run) ? null : agentFactsFor(store, run.id, access, who.name);
452
453
  if (facts !== null) {
453
- // Opening a finished agent is viewing it: it leaves this person's unread Done.
454
- if (facts.outcome !== null && markAgentSeen(store, who.name, facts.runId, now))
455
- facts.seen = true;
454
+ // Opening a finished agent is viewing it: the shell says so with a keyed POST /agents/seen (easy-start N1: a page
455
+ // load never writes), which leaves this person's unread Done.
456
456
  const live = facts.outcome === null;
457
457
  const composer = live ? steerControlsFor(store, facts.taskId, facts.runId, now, who.role === "approver") : null;
458
458
  const sealed = live ? null : sealedDiffOf(store, evidenceRoot, facts.runId);
@@ -703,7 +703,7 @@ export function createPagesHandlers(runtime) {
703
703
  // connects its builder; there is no separate restart or binding step.
704
704
  // The admission asks again, inside its registry lock and its transaction, about the repo it proves.
705
705
  const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
706
- const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed });
706
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
707
707
  if (!admitted.ok) {
708
708
  return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, admitted.reason === "moved" ? 409 : 400, safeReturn(body.get("return")), "open");
709
709
  }
@@ -843,7 +843,10 @@ export function createPagesHandlers(runtime) {
843
843
  return projectsScreen(response, who, "naming where repositories live takes --project-root", 400, "/", "clone");
844
844
  // CONFIRM: password, then session liveness AGAIN via lookupSession
845
845
  // (finding 22/33) — the record is consumed from the RETURNED live
846
- // session, synchronously, before the first await.
846
+ // session, synchronously, before the first await. A full session types
847
+ // its password (or Passwordless stands in); a laptop's local owner has
848
+ // none: inside this row's add-project admission the prover's local-owner
849
+ // branch proves it (scope.ts proveAccountNow), whatever the field holds.
847
850
  const password = body.get("token") ?? "";
848
851
  if (!authenticateApprover(who, password).ok) {
849
852
  return projectsScreen(response, who, "cloning takes your password, typed again", 403, "/", "clone");
@@ -891,7 +894,7 @@ export function createPagesHandlers(runtime) {
891
894
  return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400, "/", "clone");
892
895
  }
893
896
  const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
894
- const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed });
897
+ const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
895
898
  if (!joined.ok) {
896
899
  return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400, "/", "clone");
897
900
  }
@@ -953,7 +956,7 @@ export function createPagesHandlers(runtime) {
953
956
  ? "Choose a projects folder once: toolroll up --project-root <dir>"
954
957
  : "choose which folder this server may use with --project-root <dir>",
955
958
  }
956
- : { enabled: true, roots: ceiling.roots, record: [...(who.session.onboard?.entries() ?? [])][0] ?? null };
959
+ : { enabled: true, roots: ceiling.roots, localOwner: who.authority !== "full", record: [...(who.session.onboard?.entries() ?? [])][0] ?? null };
957
960
  const peeks = {};
958
961
  for (const one of recent)
959
962
  peeks[one.path] = peekOf(one.path, now);