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.
- package/CHANGELOG.md +41 -0
- package/README.md +4 -3
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +7 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +110 -0
- package/dist/folder-look.js +306 -0
- package/dist/guides.js +4 -3
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +53 -0
- package/dist/operate.js +277 -49
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-identity.d.ts +44 -0
- package/dist/project-identity.js +153 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +91 -17
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +5 -2
- package/dist/server/flows.js +13 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +9 -0
- package/dist/server/pages.js +102 -92
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-pages.d.ts +12 -16
- package/dist/server/render-pages.js +30 -69
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.js +7 -14
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +8 -1
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +211 -71
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/tasks.js +19 -5
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
package/dist/server/pages.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 ? [
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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 (
|
|
138
|
-
|
|
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),
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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
|
|
912
|
-
const admitted =
|
|
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
|
|
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 =
|
|
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
|
|
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,
|
|
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("
|
|
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(
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
459
|
-
return page(response, 400, signupPage("
|
|
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 <
|
|
462
|
-
return page(response, 400, signupPage("
|
|
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("
|
|
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("
|
|
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;
|