toolroll 0.9.82 → 0.9.84

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/dispatch.d.ts +15 -0
  15. package/dist/dispatch.js +26 -4
  16. package/dist/first-run.d.ts +5 -2
  17. package/dist/first-run.js +13 -2
  18. package/dist/first-tasks-child.d.ts +1 -0
  19. package/dist/first-tasks-child.js +11 -0
  20. package/dist/folder-look.d.ts +110 -0
  21. package/dist/folder-look.js +306 -0
  22. package/dist/guides.js +4 -3
  23. package/dist/learning-child.d.ts +1 -0
  24. package/dist/learning-child.js +34 -0
  25. package/dist/learning-look.d.ts +25 -0
  26. package/dist/learning-look.js +72 -0
  27. package/dist/model-catalog.d.ts +5 -0
  28. package/dist/model-catalog.js +11 -2
  29. package/dist/model-choices.d.ts +188 -0
  30. package/dist/model-choices.js +405 -0
  31. package/dist/model-names.d.ts +6 -0
  32. package/dist/model-names.js +34 -0
  33. package/dist/models-ui.d.ts +69 -5
  34. package/dist/models-ui.js +53 -10
  35. package/dist/needs-you.d.ts +8 -0
  36. package/dist/needs-you.js +8 -0
  37. package/dist/operate.d.ts +53 -0
  38. package/dist/operate.js +277 -49
  39. package/dist/project-admission.d.ts +4 -1
  40. package/dist/project-admission.js +23 -2
  41. package/dist/project-identity.d.ts +44 -0
  42. package/dist/project-identity.js +153 -0
  43. package/dist/project-knowledge.d.ts +6 -6
  44. package/dist/project-tools.d.ts +4 -1
  45. package/dist/project-tools.js +7 -2
  46. package/dist/project.d.ts +4 -1
  47. package/dist/project.js +11 -4
  48. package/dist/repos.d.ts +7 -0
  49. package/dist/repos.js +57 -0
  50. package/dist/serve.js +91 -17
  51. package/dist/server/chat.js +5 -4
  52. package/dist/server/chrome.d.ts +3 -0
  53. package/dist/server/chrome.js +5 -2
  54. package/dist/server/console.css +5 -2
  55. package/dist/server/flows.js +13 -3
  56. package/dist/server/guards.d.ts +4 -0
  57. package/dist/server/guards.js +12 -3
  58. package/dist/server/http.d.ts +12 -0
  59. package/dist/server/http.js +9 -0
  60. package/dist/server/pages.js +102 -92
  61. package/dist/server/people-tokens.js +40 -20
  62. package/dist/server/render-pages.d.ts +12 -16
  63. package/dist/server/render-pages.js +30 -69
  64. package/dist/server/render-people.d.ts +14 -2
  65. package/dist/server/render-people.js +35 -12
  66. package/dist/server/render-settings.js +1 -0
  67. package/dist/server/render-tasks.js +7 -14
  68. package/dist/server/request-context.d.ts +2 -0
  69. package/dist/server/route-table.js +8 -1
  70. package/dist/server/runtime.d.ts +4 -0
  71. package/dist/server/runtime.js +1 -0
  72. package/dist/server/settings.d.ts +58 -1
  73. package/dist/server/settings.js +211 -71
  74. package/dist/server/signup-gate.d.ts +9 -0
  75. package/dist/server/signup-gate.js +40 -0
  76. package/dist/server/tasks.js +19 -5
  77. package/dist/settings-kit-script.d.ts +21 -0
  78. package/dist/settings-kit-script.js +67 -0
  79. package/dist/settings-kit.d.ts +137 -0
  80. package/dist/settings-kit.js +186 -0
  81. package/dist/settings-proofs.d.ts +64 -0
  82. package/dist/settings-proofs.js +101 -0
  83. package/dist/setup-guide.js +4 -2
  84. package/dist/spend-report.d.ts +87 -0
  85. package/dist/spend-report.js +265 -0
  86. package/dist/spend-ui.d.ts +29 -8
  87. package/dist/spend-ui.js +74 -39
  88. package/dist/task-sizing.d.ts +5 -3
  89. package/dist/task-sizing.js +4 -4
  90. package/dist/up-processes.d.ts +13 -0
  91. package/dist/up-processes.js +6 -2
  92. package/package.json +1 -1
@@ -5,10 +5,12 @@ import { handlersOf } from './handler-registry.js';
5
5
  import { html, joinHtml, postForm, reauth } from "../html.js";
6
6
  /** pages handlers, moved without changing their route bodies. */
7
7
  import { createHmac, randomBytes } from "node:crypto";
8
- import { existsSync, opendirSync, readdirSync, realpathSync } from "node:fs";
8
+ import { realpathSync } from "node:fs";
9
9
  import {} from "node:http";
10
10
  import { homedir, hostname } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { basename, dirname, join, resolve } from "node:path";
12
+ import { buildersFor } from "../dispatch.js";
13
+ import { listFolder, localOrigins } from "../folder-look.js";
12
14
  import { Readable } from "node:stream";
13
15
  import { pipeline } from "node:stream/promises";
14
16
  import { setImmediate as yieldEventLoop } from "node:timers/promises";
@@ -49,13 +51,13 @@ import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRo
49
51
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
50
52
  import { assignmentOf } from "../assignment.js";
51
53
  import { assignmentTaskStatusOf } from "../assignment-presentation.js";
52
- import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
54
+ import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, portfolioOverview, projectsPage, startBuilderHtml, systemPage, workbenchRail } from "./render-pages.js";
53
55
  import { capsPage } from "./render-settings.js";
54
56
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
55
57
  import {} from "./session.js";
56
58
  import { rereadBrowser } from "./live.js";
57
59
  export function createPagesHandlers(runtime) {
58
- const { clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
60
+ const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
59
61
  // The filesystem browser (operator request): pick a project folder by
60
62
  // looking, not typing. CONFINED to exactly what opening allows —
61
63
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -67,40 +69,32 @@ export function createPagesHandlers(runtime) {
67
69
  const { url, who, response } = ctx;
68
70
  if (who.via !== "cookie")
69
71
  return refuse(response, who, 403, "browsing feeds a browser session's act");
70
- const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [realpathSync(homedir())] : [];
72
+ const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [identities.hot(homedir())] : [];
71
73
  if (browseRoots.length === 0) {
72
74
  return refuse(response, who, 404, "this server was configured with an explicit repo list — the openable projects are all on the projects page", "/projects");
73
75
  }
74
76
  const asked = url.searchParams.get("at") ?? browseRoots[0];
75
- const canonical = canonicalProject(asked);
76
- const inside = canonical !== null &&
77
- browseRoots.some(root => canonical === root || canonical.startsWith(`${root}/`));
78
- if (canonical === null || !inside) {
77
+ const within = (path) => browseRoots.some(root => path === root || path.startsWith(`${root}/`));
78
+ // Spelled outside every root: refused before anything is read. Inside, the look resolves links and checks again.
79
+ if (!within(resolve(asked))) {
79
80
  return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
80
81
  }
81
- let entries = [];
82
- try {
83
- entries = readdirSync(canonical, { withFileTypes: true })
84
- .filter(one => one.isDirectory() || one.isSymbolicLink())
85
- .filter(one => !one.name.startsWith(".") && one.name !== "node_modules")
86
- .slice(0, 400)
87
- .map(one => {
88
- const path = join(canonical, one.name);
89
- // Resolve NOW so a symlink pointing outside the fence renders
90
- // as nothing rather than as a door.
91
- const resolved = canonicalProject(path);
92
- if (resolved === null || !browseRoots.some(root => resolved === root || resolved.startsWith(`${root}/`))) {
93
- return null;
94
- }
95
- return { name: one.name, path: resolved, git: existsSync(join(resolved, ".git")) };
96
- })
97
- .filter((one) => one !== null)
98
- .sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
99
- .slice(0, 200);
82
+ // Read off the console's thread (folder-look.ts): a folder that doesn't answer never freezes the console.
83
+ const looked = await listFolder(asked, browseRoots, { ...(options.folderLook === undefined ? {} : { runner: options.folderLook }), ...(options.folderLookMs === undefined ? {} : { deadlineMs: options.folderLookMs }) });
84
+ if (!looked.ok) {
85
+ return looked.why === "no-answer"
86
+ ? refuse(response, who, 503, "This folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.", "/projects")
87
+ : refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
100
88
  }
101
- catch {
102
- return refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
89
+ const canonical = looked.value.at;
90
+ if (canonical === null || !within(canonical)) {
91
+ return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
103
92
  }
93
+ // The look kept only folders inside a root; checked here again, as before.
94
+ const entries = looked.value.entries
95
+ .filter(one => within(one.path))
96
+ .sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
97
+ .slice(0, 200);
104
98
  const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
105
99
  const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
106
100
  const csrf = who.session.csrf;
@@ -133,48 +127,28 @@ export function createPagesHandlers(runtime) {
133
127
  // (GitHub's rule); the FIRST match wins.
134
128
  const localByIdentity = new Map();
135
129
  const registered = new Set();
136
- for (const project of store.listProjects()) {
137
- if (!visible(project.path))
138
- continue;
139
- registered.add(project.path);
140
- const identity = githubIdentityOf(originUrlOf(project.path) ?? "");
141
- if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
142
- localByIdentity.set(identity.toLowerCase(), project.path);
143
- }
144
- }
130
+ for (const project of store.listProjects())
131
+ if (visible(project.path))
132
+ registered.add(project.path);
145
133
  // Roots are walked ONLY when there are listed repos to match against
146
- // (finding 1), through an iterator that stops at 400 entries — a
147
- // directory with a million children costs 400 stats, not a scan.
148
- if (listed !== null && listed.ok && listed.repos.length > 0) {
149
- for (const root of ceiling.roots) {
150
- const children = [];
151
- try {
152
- const dir = opendirSync(root);
153
- try {
154
- for (let seen = 0; seen < 400; seen++) {
155
- const entry = dir.readSync();
156
- if (entry === null)
157
- break;
158
- if (entry.isDirectory())
159
- children.push(join(root, entry.name));
160
- }
161
- }
162
- finally {
163
- dir.closeSync();
164
- }
165
- }
166
- catch {
167
- // an unreadable root offers nothing
168
- }
169
- for (const child of children) {
170
- if (!existsSync(join(child, ".git")))
171
- continue;
172
- const identity = githubIdentityOf(originUrlOf(child) ?? "");
173
- if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
174
- localByIdentity.set(identity.toLowerCase(), child);
175
- }
134
+ // (finding 1), at most 400 folders a root. Every read runs off the
135
+ // console's thread (folder-look.ts): a folder that doesn't answer
136
+ // leaves its copies unmatched and the page says so.
137
+ const roots = listed !== null && listed.ok && listed.repos.length > 0 ? [...ceiling.roots] : [];
138
+ let localUnanswered = false;
139
+ if (registered.size > 0 || roots.length > 0) {
140
+ const looked = await localOrigins([...registered], roots, { ...(options.folderLook === undefined ? {} : { runner: options.folderLook }), ...(options.folderLookMs === undefined ? {} : { deadlineMs: options.folderLookMs }) });
141
+ if (looked.ok) {
142
+ const children = looked.value.found.filter(one => roots.some(root => one.path.startsWith(`${root}/`)));
143
+ for (const one of [...looked.value.registered.filter(row => registered.has(row.path)), ...children]) {
144
+ const identity = githubIdentityOf(one.url ?? "");
145
+ if (identity !== null && !localByIdentity.has(identity.toLowerCase()))
146
+ localByIdentity.set(identity.toLowerCase(), one.path);
176
147
  }
177
148
  }
149
+ else {
150
+ localUnanswered = looked.why === "no-answer";
151
+ }
178
152
  }
179
153
  return sendScreen(response, 200, githubReposPage(chromeFor(who.via === "cookie" ? who.session.project : null, "projects"), {
180
154
  listed,
@@ -183,6 +157,7 @@ export function createPagesHandlers(runtime) {
183
157
  csrf: who.via === "cookie" ? who.session.csrf : "",
184
158
  cloneReady: ceiling.roots.length > 0,
185
159
  openProject: who.via === "cookie" ? who.session.project ?? null : null,
160
+ localUnanswered,
186
161
  }));
187
162
  }
188
163
  // List repositories / Refresh list: ask the GitHub CLI signed in on this machine, then show the page.
@@ -354,7 +329,7 @@ export function createPagesHandlers(runtime) {
354
329
  runners: store.listRunners(),
355
330
  worktrees: store
356
331
  .listWorktrees()
357
- .filter(one => project === null || sameRepo(one.repo, project)),
332
+ .filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
358
333
  episode: project === null ? null : store.latestWatchEpisode(project),
359
334
  now,
360
335
  }));
@@ -378,7 +353,7 @@ export function createPagesHandlers(runtime) {
378
353
  }),
379
354
  building: store.liveClaims(project, now),
380
355
  runners: store.listRunners(),
381
- worktrees: store.listWorktrees().filter(one => project === null || sameRepo(one.repo, project)),
356
+ worktrees: store.listWorktrees().filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
382
357
  episode: project === null ? null : store.latestWatchEpisode(project),
383
358
  outboxPending: store.pendingForAttention().length,
384
359
  // External work at a glance (arc 3 finding 24): every dispatch
@@ -665,10 +640,18 @@ export function createPagesHandlers(runtime) {
665
640
  return refuse(response, who, 403, "selecting a project is a browser session's act");
666
641
  }
667
642
  const askedPath = (body.get("path") ?? "").trim();
668
- const canonicalPick = askedPath === "" ? null : canonicalProject(askedPath);
643
+ // Resolved off the console's thread (project-identity.ts): a folder that doesn't answer is said, never waited on.
644
+ const decidedOn = identities.generation();
645
+ const picked = askedPath === "" ? null : await identities.resolveNow(askedPath);
646
+ if (picked === "no-answer")
647
+ return refuse(response, who, 503, "That project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.");
648
+ const canonicalPick = picked;
669
649
  if ((askedPath !== "" && canonicalPick === null) || (canonicalPick !== null && (!visible(canonicalPick) || !(await authorizedProject(liveCeiling(), canonicalPick))))) {
670
650
  return refuse(response, who, 403, "that project is outside this console's reach");
671
651
  }
652
+ // An identity that changed during the awaits above undoes what they proved.
653
+ if (identities.generation() !== decidedOn)
654
+ return refuse(response, who, 409, "a project folder changed while this was being checked — try again");
672
655
  who.session.project = canonicalPick;
673
656
  who.session.projectRevision += 1;
674
657
  sessions.persist(who.session);
@@ -702,7 +685,12 @@ export function createPagesHandlers(runtime) {
702
685
  return refuse(response, who, 403, "opening a project is a browser session's act");
703
686
  }
704
687
  const asked = (body.get("path") ?? "").trim();
705
- const canonical = asked === "" ? null : canonicalProject(asked);
688
+ const decidedOn = identities.generation();
689
+ const resolved = asked === "" ? null : await identities.resolveNow(asked);
690
+ if (resolved === "no-answer") {
691
+ return void projectsScreen(response, who, "that folder didn't answer — it may be on a drive that's disconnected, or waiting on macOS; try again in a moment", 503, safeReturn(body.get("return")));
692
+ }
693
+ const canonical = resolved;
706
694
  if (canonical === null) {
707
695
  return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
708
696
  }
@@ -720,13 +708,21 @@ export function createPagesHandlers(runtime) {
720
708
  if (!visible(canonical)) {
721
709
  return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
722
710
  }
711
+ // An identity that changed during the awaits above undoes what they proved.
712
+ if (identities.generation() !== decidedOn)
713
+ return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")));
723
714
  // Opening an allowed repository admits it the same way `repos add` does
724
715
  // (project-admission.ts). A co-located `up` notices the durable list and
725
716
  // connects its builder; there is no separate restart or binding step.
726
- const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
717
+ // The admission asks again, inside its registry lock and its transaction, about the repo it proves.
718
+ const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
719
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed });
727
720
  if (!admitted.ok) {
728
- 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}`, 400, safeReturn(body.get("return")));
721
+ 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")));
729
722
  }
723
+ // And once more before the session follows it.
724
+ if (!stillAllowed(admitted.repo))
725
+ return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")));
730
726
  deletedRepos.delete(admitted.repo);
731
727
  who.session.project = admitted.repo;
732
728
  who.session.projectRevision++;
@@ -807,13 +803,9 @@ export function createPagesHandlers(runtime) {
807
803
  const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
808
804
  if (rootRaw === undefined)
809
805
  return projectsScreen(response, who, "pick one of the configured roots", 400);
810
- let root;
811
- try {
812
- root = realpathSync(rootRaw);
813
- }
814
- catch {
806
+ const root = await identities.resolveNow(rootRaw);
807
+ if (root === null || root === "no-answer")
815
808
  return projectsScreen(response, who, "that projects root does not resolve right now", 400);
816
- }
817
809
  const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
818
810
  if (!previewed.ok)
819
811
  return projectsScreen(response, who, previewed.message, 400);
@@ -880,19 +872,17 @@ export function createPagesHandlers(runtime) {
880
872
  return projectsScreen(response, who, "that preview expired or was already used — preview again", 400);
881
873
  }
882
874
  live.onboard?.delete(nonce); // consumed BEFORE the first await
875
+ // Identities as this confirmation found them: the admission below re-checks against them (project-identity.ts).
876
+ const decidedOn = identities.generation();
883
877
  if (record.large && body.get("big-ok") !== "1") {
884
878
  return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400);
885
879
  }
886
880
  const rootRaw = ceiling.roots[record.rootIndex];
887
881
  if (rootRaw === undefined)
888
882
  return projectsScreen(response, who, "the configured roots changed — preview again", 400);
889
- let root;
890
- try {
891
- root = realpathSync(rootRaw);
892
- }
893
- catch {
883
+ const root = await identities.resolveNow(rootRaw);
884
+ if (root === null || root === "no-answer")
894
885
  return projectsScreen(response, who, "that projects root does not resolve right now", 400);
895
- }
896
886
  if (dirname(record.target) !== root)
897
887
  return projectsScreen(response, who, "the configured roots changed — preview again", 400);
898
888
  const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
@@ -908,15 +898,18 @@ export function createPagesHandlers(runtime) {
908
898
  return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
909
899
  }
910
900
  // authorizedProject AFTER the repository exists (finding 20).
911
- const proved = await authorizedProject(liveCeiling(), cloned.target);
912
- const admitted = proved ? (canonicalProject(cloned.target) ?? cloned.target) : null;
901
+ const clonedAt = await identities.resolveNow(cloned.target);
902
+ const admitted = typeof clonedAt === "string" && clonedAt !== "no-answer" && (await authorizedProject(liveCeiling(), clonedAt)) ? clonedAt : null;
913
903
  if (admitted === null) {
914
904
  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);
915
905
  }
916
- const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
906
+ const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
907
+ const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed });
917
908
  if (!joined.ok) {
918
- return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
909
+ return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400);
919
910
  }
911
+ if (!stillAllowed(joined.repo))
912
+ return projectsScreen(response, who, `${cloned.target} is cloned but wasn't opened — a project folder changed while it was being checked; try again`, 409);
920
913
  deletedRepos.delete(admitted);
921
914
  who.session.project = admitted;
922
915
  who.session.projectRevision += 1;
@@ -937,7 +930,7 @@ export function createPagesHandlers(runtime) {
937
930
  const recentPaths = new Set(recent.map(one => one.path));
938
931
  const candidates = new Set();
939
932
  for (const path of consoleProjects()) {
940
- const canonical = canonicalProject(path) ?? path;
933
+ const canonical = identities.hot(path);
941
934
  if (!recentPaths.has(canonical) && visible(canonical))
942
935
  candidates.add(canonical);
943
936
  }
@@ -1084,6 +1077,22 @@ export function createPagesHandlers(runtime) {
1084
1077
  return respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1085
1078
  return respond(response, 200, "application/json", JSON.stringify(window.ok ? window : { error: window.reason }));
1086
1079
  }
1080
+ /** Start a builder (Settings revamp S4): the command, and whether a builder that may build the open project is
1081
+ * checking in, as dispatch decides. Read-only; a project-limited person sees only a project they may see. */
1082
+ async function startBuilderPage(ctx) {
1083
+ const { response, now, project, url, who } = ctx;
1084
+ // ?task=<id>: that task's own saved project (a builder there, as dispatch decides for it), never the open one; the
1085
+ // session's selection is left alone. Unknown, unplaced or not visible to this person: 404, the same answer.
1086
+ const taskId = url.searchParams.get("task");
1087
+ const ref = taskId === null ? null : store.lookupRef(taskId.slice(0, 200));
1088
+ if (taskId !== null && (ref === null || ref.repo === null || !visible(ref.repo)))
1089
+ return refuse(response, who, 404, "That task is not available.", "/work");
1090
+ const shown = ref !== null ? ref.repo : project !== null && visible(project) ? project : null;
1091
+ const connected = shown === null ? null : buildersFor(store, shown, now, ref?.assignedRunner ?? null).alive.length > 0;
1092
+ return sendScreen(response, 200, screen("Start a builder", startBuilderHtml({
1093
+ heading: "h1", project: shown === null ? null : basename(shown), connected, managed: !restricted(),
1094
+ }), { chrome: chromeFor(project, "none") }));
1095
+ }
1087
1096
  const registrations = handlersOf("pages", {
1088
1097
  "projects.browse": projectsBrowse,
1089
1098
  "projects.github": projectsGithub,
@@ -1095,6 +1104,7 @@ export function createPagesHandlers(runtime) {
1095
1104
  "ledger": ledgerPage,
1096
1105
  "activity": activityPage,
1097
1106
  "system": systemScreen,
1107
+ "start-builder": startBuilderPage,
1098
1108
  "peek": peekPage,
1099
1109
  "agents": agentsPage,
1100
1110
  "agents.live": agentsLive,
@@ -12,7 +12,7 @@ import { SSO_CALLBACK } from "../sso-settings.js";
12
12
  import { personChip, refuse, screen } from "./chrome.js";
13
13
  import { redirect, respond, sayTo } from "./http.js";
14
14
  /** people handlers, moved without changing their route bodies. */
15
- import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { randomBytes } from "node:crypto";
16
16
  import {} from "node:http";
17
17
  import {} from "../contracts/console-api.js";
18
18
  import { listCoordinators } from "../coordinator.js";
@@ -22,8 +22,9 @@ import { personAuditHtml, personHref } from "../people-audit-ui.js";
22
22
  import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
23
23
  import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
24
24
  import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
25
- import { joinDeadPage, joinFormPage, loginPage, signupPage } from "./render-people.js";
25
+ import { joinDeadPage, joinFormPage, loginPage, SIGNUP_NAME, SIGNUP_PASSWORD_MIN, signupPage } from "./render-people.js";
26
26
  import { SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
27
+ import { setupCodeMatches, signupCookie, signupFromHere } from "./signup-gate.js";
27
28
  /** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
28
29
  * running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
29
30
  * the one allowance backward is that rounding, never a check made before the visit began. */
@@ -39,7 +40,7 @@ export function sameVisitCheck(authTime, startedAt, now) {
39
40
  return Math.min(Math.max(at, startedAt), now);
40
41
  }
41
42
  export function createPeopleHandlers(runtime) {
42
- const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
43
+ const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
43
44
  // v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
44
45
  async function sessionsPage(ctx) {
45
46
  const { url, who, response, project } = ctx;
@@ -307,6 +308,11 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
307
308
  async function signInPage(ctx) {
308
309
  const { url, response } = ctx;
309
310
  if (options.setupCode !== undefined && store.listApprovers().length === 0) {
311
+ // The form's same-site proof (signup-gate.ts): a post without it never reaches the setup code.
312
+ if (runtime.setupAttemptsLeft > 0)
313
+ response.setHeader("Set-Cookie", signupCookie(cookieSecure));
314
+ // Its own post then carries this console's Origin (the console's default no-referrer sends `null`).
315
+ response.setHeader("Referrer-Policy", "same-origin");
310
316
  return page(response, 200, signupPage(null, runtime.setupAttemptsLeft));
311
317
  }
312
318
  return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
@@ -334,7 +340,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
334
340
  return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
335
341
  const id = randomBytes(32).toString("hex");
336
342
  sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
337
- project: defaultProject, projectRevision: 1 });
343
+ project: runtime.defaultProject, projectRevision: 1 });
338
344
  recordSignIn(held.account, "signed in", "one-time link");
339
345
  response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
340
346
  return redirect(response, "/chat");
@@ -429,44 +435,58 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
429
435
  const account = store.accountOf(signedIn.account);
430
436
  const id = randomBytes(32).toString("hex");
431
437
  sessions.set(id, { ...arrival(request), name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
432
- project: defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
438
+ project: runtime.defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
433
439
  recordSignIn(signedIn.account, "signed in", settings.label);
434
440
  response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
435
441
  return redirect(response, handoff.returnTo);
436
442
  }
437
443
  async function signupSend(ctx) {
438
444
  const { request, response } = ctx;
445
+ // A re-shown form posts again: its Origin must name this console too (signup-gate.ts).
446
+ response.setHeader("Referrer-Policy", "same-origin");
439
447
  // Only while the table is empty, only with the printed code, only
440
448
  // five tries: the bootstrap door underneath is atomic, so two first
441
449
  // visitors serialize to one owner.
442
450
  if (options.setupCode === undefined || store.listApprovers().length > 0) {
443
- return page(response, 409, loginPage("an account already exists — sign in"));
451
+ return page(response, 409, loginPage("An account already exists. Sign in."));
444
452
  }
445
453
  if (runtime.setupAttemptsLeft <= 0) {
446
- return page(response, 403, signupPage("too many wrong codes — restart the server to get a fresh code", 0));
454
+ return page(response, 403, signupPage(null, 0));
455
+ }
456
+ // Another site's post (or one without this page's cookie) is refused before it can spend a try.
457
+ if (!signupFromHere(request, runtime.allowedHost)) {
458
+ request.resume();
459
+ response.setHeader("Set-Cookie", signupCookie(cookieSecure));
460
+ return page(response, 403, signupPage("This form had expired, so it was reloaded. Enter the details again.", runtime.setupAttemptsLeft));
447
461
  }
448
462
  const body = readForm(await form(request), CONSOLE_FORMS.signup);
449
463
  const code = (body.get("code") ?? "").trim();
450
464
  const name = (body.get("name") ?? "").trim();
451
465
  const password = body.get("password") ?? "";
452
- const given = Buffer.from(code.padEnd(64, " "), "utf8");
453
- const wanted = Buffer.from(options.setupCode.padEnd(64, " "), "utf8");
454
- if (code === "" || !timingSafeEqual(given, wanted)) {
466
+ // Re-read after the body: concurrent posts all waited above, and only the tries still left may be compared.
467
+ // From here to the decrement nothing awaits, so check and spend are one step.
468
+ if (runtime.setupAttemptsLeft <= 0) {
469
+ return page(response, 403, signupPage(null, 0));
470
+ }
471
+ if (!setupCodeMatches(code, options.setupCode)) {
455
472
  runtime.setupAttemptsLeft -= 1;
456
- return page(response, 403, signupPage("that is not the setup code the server printed", runtime.setupAttemptsLeft));
473
+ // What was typed comes back except the code and the password (never echoed); the count rides the code's note.
474
+ return page(response, 403, signupPage(runtime.setupAttemptsLeft > 0
475
+ ? { field: "code", text: "That isn't the setup code Toolroll printed. Check the terminal and try again." }
476
+ : "That wasn't the setup code, and that was the last try. Restart Toolroll to get a new code.", runtime.setupAttemptsLeft, name));
457
477
  }
458
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
459
- return page(response, 400, signupPage("a username is letters, digits, dots, dashes — up to 64", runtime.setupAttemptsLeft));
478
+ if (!SIGNUP_NAME.test(name)) {
479
+ return page(response, 400, signupPage({ field: "name", text: "Use only letters, digits, dots, dashes or underscores, starting with a letter or digit." }, runtime.setupAttemptsLeft, name));
460
480
  }
461
- if (password.length < 8) {
462
- return page(response, 400, signupPage("a password is at least 8 characters", runtime.setupAttemptsLeft));
481
+ if (password.length < SIGNUP_PASSWORD_MIN) {
482
+ return page(response, 400, signupPage({ field: "password", text: `Use at least ${SIGNUP_PASSWORD_MIN} characters.` }, runtime.setupAttemptsLeft, name));
463
483
  }
464
484
  const made = store.bootstrapApproverIfNone(name, hashPassword(password), clock());
465
485
  if (!made.ok)
466
- return page(response, 409, loginPage("an account already exists — sign in"));
486
+ return page(response, 409, loginPage("An account already exists. Sign in."));
467
487
  const authenticated = authenticateAccount(store, name, password);
468
488
  if (authenticated === null || !authenticated.ok)
469
- return page(response, 500, loginPage("the account was created but could not sign in — try again"));
489
+ return page(response, 500, loginPage("The account was created, but signing in failed. Sign in with it now."));
470
490
  const id = randomBytes(32).toString("hex");
471
491
  sessions.set(id, {
472
492
  ...arrival(request),
@@ -477,7 +497,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
477
497
  createdAt: Date.now(),
478
498
  sawBoardAt: null,
479
499
  lastSeen: Date.now(),
480
- project: defaultProject,
500
+ project: runtime.defaultProject,
481
501
  projectRevision: 1,
482
502
  // A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
483
503
  ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
@@ -528,7 +548,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
528
548
  createdAt: Date.now(),
529
549
  sawBoardAt: null,
530
550
  lastSeen: Date.now(),
531
- project: defaultProject,
551
+ project: runtime.defaultProject,
532
552
  projectRevision: 1,
533
553
  // A password typed to sign in on this computer starts this session's freshness window (cockpit B2); from anywhere
534
554
  // else (a phone, a proxy) it signs in and proves nothing more.
@@ -615,7 +635,7 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
615
635
  createdAt: Date.now(),
616
636
  sawBoardAt: null,
617
637
  lastSeen: Date.now(),
618
- project: defaultProject,
638
+ project: runtime.defaultProject,
619
639
  projectRevision: 1,
620
640
  });
621
641
  response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
@@ -112,6 +112,16 @@ export declare function inboxPage(chrome: Chrome, data: {
112
112
  unread?: readonly InboxTab[];
113
113
  }): Screen;
114
114
  /** System: the machinery — workers, background service, workspaces. */
115
+ /** Start a builder: the one command, as text to select (/start-builder, where every "Connect a builder" leads, and
116
+ * /system#builder). `project`: the open project's name, when the viewer may see it; `connected`: whether a builder
117
+ * that may build there is checking in (null: say nothing); `managed`: whether this person can start one themselves
118
+ * (registering a builder takes an instance operator), else who to ask. */
119
+ export declare function startBuilderHtml(data: {
120
+ heading: "h1" | "h2";
121
+ project: string | null;
122
+ connected: boolean | null;
123
+ managed: boolean;
124
+ }): Html;
115
125
  export declare function systemPage(chrome: Chrome, data: {
116
126
  agents: {
117
127
  phase: "plan" | "build" | "repair" | "review";
@@ -422,22 +432,6 @@ export type ProjectPeek = {
422
432
  * GitHub identity. Everything else is null.
423
433
  */
424
434
  export declare function githubIdentityOf(remoteUrl: string): string | null;
425
- /**
426
- * The `origin` remote's url from a repository's OWN .git/config. BOUNDED
427
- * I/O by construction (ghlist review, finding 1): the final component may
428
- * not be a symlink (O_NOFOLLOW + lstat), must be a REGULAR file under a
429
- * size cap, and at most 64 KiB are ever read — a sparse monster or a fifo
430
- * planted as a "config" reads as null, never as a hang. Parsed LINE BY
431
- * LINE with real section tracking (finding 2): a `[remote "origin"]`
432
- * embedded inside some other value never opens the section. null when
433
- * unreadable or origin-less; a worktree-style `.git` FILE (gitdir pointer)
434
- * reads null too, which is honest — its identity lives elsewhere. The
435
- * answer is ADVISORY metadata for offering a button: the /projects/open
436
- * road re-proves path, ceiling, and git-ness before anything mutates, and
437
- * a config that lies about its origin can only mislabel a repository the
438
- * operator was already allowed to open.
439
- */
440
- export declare function originUrlOf(repoPath: string): string | null;
441
435
  /** The GitHub listing page: the account's repositories with ONE honest
442
436
  * action each. Names, descriptions, and paths are gh/filesystem DATA —
443
437
  * escaped at the sink like everything else. */
@@ -450,6 +444,8 @@ export declare function githubReposPage(chrome: Chrome, data: {
450
444
  csrf: string;
451
445
  cloneReady: boolean;
452
446
  openProject: string | null;
447
+ /** A projects folder didn't answer in time: local copies weren't matched this time. */
448
+ localUnanswered?: boolean;
453
449
  }): Screen;
454
450
  export declare function projectsPage(chrome: Chrome, recent: {
455
451
  path: string;