toolroll 0.9.82 → 0.9.83

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/dist/repos.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * to edit it by hand or check it into their dotfiles — which also means it is
11
11
  * untrusted input and gets validated like any other.
12
12
  */
13
+ import { closeSync as closeFd, constants as fsConstants, fstatSync, openSync as openFd, readSync, renameSync as renameFile, writeFileSync } from "node:fs";
13
14
  import { mkdir, readFile, writeFile } from "node:fs/promises";
14
15
  import { dirname } from "node:path";
15
16
  import { configBase, namedPath } from "./names.js";
@@ -213,6 +214,11 @@ export async function updateProjectRegistry(file, transform) {
213
214
  held = false; // abandon: no unlink, no publication
214
215
  return { ok: false, reason: "abandoned", message: "the registry lock aged out before the update could publish — nothing changed; try again" };
215
216
  }
217
+ // Each line this update removes counts one more removal, written BEFORE the list is published: whoever holds a
218
+ // start's authority for a saved line (upDeferredRetry) sees that it was removed, even if it was saved again since.
219
+ const gone = current.repos.filter(one => !next.repos.includes(one));
220
+ if (gone.length > 0)
221
+ bumpRegistryRemovals(file, gone, `${process.pid}.${token.slice(0, 8)}`);
216
222
  const temp = `${file}.${process.pid}.${token.slice(0, 8)}.tmp`;
217
223
  const fd = openSync(temp, "wx");
218
224
  writeSync(fd, `${JSON.stringify({ version: CONFIG_VERSION, ...next }, null, 2)}
@@ -267,3 +273,54 @@ function readProjectRegistry(parsed) {
267
273
  function sortUnique(repos) {
268
274
  return [...new Set(repos)].sort();
269
275
  }
276
+ /** Beside the saved list: how many times each line has been removed from it (written under its lock). */
277
+ export function registryRemovalsFile(file) {
278
+ return `${file}.removals.json`;
279
+ }
280
+ /**
281
+ * How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
282
+ * as one answers null: its caller treats every line as changed (fail closed).
283
+ */
284
+ export function registryRemovals(file) {
285
+ let fd;
286
+ try {
287
+ fd = openFd(registryRemovalsFile(file), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
288
+ }
289
+ catch (error) {
290
+ return error.code === "ENOENT" ? {} : null;
291
+ }
292
+ try {
293
+ const held = fstatSync(fd);
294
+ if (!held.isFile() || held.size > 4 * 1024 * 1024)
295
+ return null;
296
+ const buffer = Buffer.alloc(held.size);
297
+ const read = held.size === 0 ? 0 : readSync(fd, buffer, 0, buffer.length, 0);
298
+ const parsed = JSON.parse(buffer.toString("utf8", 0, read));
299
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
300
+ return null;
301
+ const out = {};
302
+ for (const [line, count] of Object.entries(parsed)) {
303
+ if (!Number.isSafeInteger(count) || count < 0)
304
+ return null;
305
+ out[line] = count;
306
+ }
307
+ return out;
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ finally {
313
+ closeFd(fd);
314
+ }
315
+ }
316
+ /** One more removal for each of `lines`, written whole and renamed into place. Called only under the list's lock. */
317
+ function bumpRegistryRemovals(file, lines, tag) {
318
+ const kept = registryRemovals(file);
319
+ // An unreadable record starts again from the clock, so no count read before can come round again.
320
+ const counts = kept ?? {};
321
+ for (const line of lines)
322
+ counts[line] = kept === null ? Date.now() : (counts[line] ?? 0) + 1;
323
+ const temp = `${registryRemovalsFile(file)}.${tag}.tmp`;
324
+ writeFileSync(temp, `${JSON.stringify(counts)}\n`, { flag: "wx", mode: 0o600 });
325
+ renameFile(temp, registryRemovalsFile(file));
326
+ }
package/dist/serve.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { secretFolder } from "./tool-secret-store.js";
2
2
  import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
+ import { syncProjectIdentities } from "./project-identity.js";
4
+ import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
3
5
  import { loadOrCreateVapidKeys } from "./push.js";
4
6
  import { checkoutPlan } from "./checkout-cleanup.js";
5
7
  import { latestReleaseFor } from "./server/render-settings.js";
@@ -38,7 +40,8 @@ import { asReadOnlyRequest } from './read-only-request.js';
38
40
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
39
41
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
40
42
  import { projectAuthority } from "./project-access.js";
41
- import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
43
+ import { discoverTools, projectToolsOf, savedProjectMcp, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
44
+ import { lookProjectMcp } from "./folder-look.js";
42
45
  import { toolRefusal } from "./policy.js";
43
46
  import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
44
47
  import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
@@ -166,6 +169,9 @@ import { loadConsoleUrl } from "./webhooks.js";
166
169
  import { reviewFactsOf } from "./workspace-ui.js";
167
170
  import { WorktreePool } from "./worktree.js";
168
171
  export function createDecisionServer(options) {
172
+ // Identities come from what the console's start resolved off its thread (startConsole); a path that didn't answer
173
+ // stays unresolved, kept restrictive. Without them (embedders, tests) the old synchronous reads apply.
174
+ const identities = options.identities ?? syncProjectIdentities();
169
175
  const { projectFamilyPeek, familiesInView, familyTasksInView, revisionDestination, runIsLive, runIsTaskResult, revisionViewOf, taskViewData, routeViewOf, taskChatFocus, pullRequestTargetOf, failureOf, explainAttempt, taskScreen, armTaskResume, planViewOf, planContractViewOf, revisionDocOf, revisionLedgerOf, resultDetailOf } = createTaskViews({
170
176
  get store() { return store; },
171
177
  get projectCounts() { return projectCounts; },
@@ -188,6 +194,7 @@ export function createDecisionServer(options) {
188
194
  const { authenticateApprover, authenticateCredential, authorizeMutation, projectOf, projectRequestAllowed, identify, lookupSession, restricted, visible, admissionList, codingActorAllowed, codingProjectAllowed, workAccess, familyOf, taskRepoOf, runVisible } = createSharedGuards({
189
195
  get store() { return store; },
190
196
  get liveCeiling() { return liveCeiling; },
197
+ identities,
191
198
  get codingProjects() { return codingProjects; },
192
199
  get unscopedMode() { return unscopedMode; },
193
200
  get managedRepos() { return managedRepos; },
@@ -448,18 +455,20 @@ export function createDecisionServer(options) {
448
455
  const approvalNonces = new Map();
449
456
  // Resolve startup authority once. Missing paths remain restrictive.
450
457
  // Native additions come only from the controller after its admission checks.
451
- const { ceiling, unresolved: unresolvedRepos } = resolveCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? []);
458
+ const { ceiling: startCeiling, unresolved: unresolvedRepos } = resolveCeiling([...(options.repo === undefined ? [] : [options.repo]), ...(options.repos ?? [])], options.projectRoots ?? [], options.identities === undefined ? undefined : identities.known);
459
+ const ceiling = { ...startCeiling, canon: identities.strict };
452
460
  /** The project every fresh session opens with: the sole configured repo, else none. */
453
461
  const defaultProject = ceiling.repos.length === 1 && ceiling.roots.length === 0 ? ceiling.repos[0] : null;
454
462
  /** No ceiling configured at all: the legacy trust-everything mode, named. */
455
- const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0;
463
+ // An `up` console (it is handed its admitted set) is never unscoped: with every saved project away it is empty.
464
+ const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0 && options.admittedRepos === undefined;
456
465
  /** Per-row visibility under the ceiling — the authorization question for reads. */
457
466
  /** The exact repositories this console serves now: `up`'s live admitted set when it supplies one, else startup's. */
458
467
  const exactRepos = () => options.admittedRepos?.() ?? ceiling.repos;
459
468
  const liveCeiling = () => {
460
469
  const repos = [...exactRepos(), ...(options.additionalProjectRepos?.() ?? [])];
461
470
  // An admitted set that is empty for now is an empty ceiling, never the legacy unscoped mode.
462
- return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots };
471
+ return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots, canon: identities.strict };
463
472
  };
464
473
  /** The explicit, currently proved project list. The callback is supplied
465
474
  * only by `up`, after it has independently checked git-ness and the root
@@ -479,7 +488,7 @@ export function createDecisionServer(options) {
479
488
  const repos = [];
480
489
  const gone = goneRepos();
481
490
  for (const path of [...exactRepos(), ...(options.currentRepos?.() ?? [])]) {
482
- const canonical = canonicalProject(path) ?? path;
491
+ const canonical = identities.hot(path);
483
492
  if (seen.has(canonical) || !visible(canonical) || gone.has(canonical))
484
493
  continue;
485
494
  seen.add(canonical);
@@ -568,7 +577,8 @@ export function createDecisionServer(options) {
568
577
  // A service that connects by signing in (or an app's own Connect) is offered only that way.
569
578
  catalog: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null && localConnectOf(one.name) === null),
570
579
  connections: connectionsOf(store, repo, toolHome), kit, wanted,
571
- found: discoverTools(repo, codex, toolHome).filter(one => !names.has(one.spec.name)),
580
+ // The project's own .mcp.json as the background pass last read it off the console's thread (never read here).
581
+ found: discoverTools(repo, codex, toolHome, process.env, options.identities === undefined ? undefined : savedProjectMcp.get(repo) ?? null).filter(one => !names.has(one.spec.name)),
572
582
  storage: toolSecretStorage(),
573
583
  };
574
584
  };
@@ -1027,7 +1037,7 @@ export function createDecisionServer(options) {
1027
1037
  }
1028
1038
  const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke", "/settings/chat-approval/save", "/settings/chat-approval/off", "/settings/project/live-view"]);
1029
1039
  const placed = url.pathname === "/tasks/add" ? body?.get("repo")?.trim() : null;
1030
- return { repo: url.pathname.startsWith("/people/") ? null : placed ? canonicalProject(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1040
+ return { repo: url.pathname.startsWith("/people/") ? null : placed ? identities.strict(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1031
1041
  action: known.has(url.pathname) ? url.pathname.slice(1).replaceAll("/", " ") : "console request" };
1032
1042
  }
1033
1043
  async function handleGet(route, url, who, request, response) {
@@ -1955,6 +1965,8 @@ export function createDecisionServer(options) {
1955
1965
  for (const provider of PROVIDER_IDS.filter(isKeyedProvider).filter(one => one !== "claude" && one !== "codex"))
1956
1966
  waits.push(connectionCheck(provider).catch(() => undefined));
1957
1967
  }
1968
+ // Project identities, resolved off the thread: what pages asked about, and answers gone stale.
1969
+ waits.push(identities.refresh([...exactRepos(), ...(options.currentRepos?.() ?? []), ...store.listProjects().map(one => one.path), ...store.knownRepos(), ...ceiling.roots]));
1958
1970
  integrations.refreshIfDue();
1959
1971
  const sso = ssoSettings();
1960
1972
  if (sso !== null && (ssoProvider === null || ssoProvider.issuer !== sso.issuer || Date.now() - ssoProvider.at >= 10 * 60_000))
@@ -1991,13 +2003,22 @@ export function createDecisionServer(options) {
1991
2003
  continue;
1992
2004
  const reading = { until: Date.now() + 10 * 60_000, found: entry?.found ?? null, reading: true };
1993
2005
  firstTasks.set(repo, reading);
1994
- waits.push(findFirstTasks(repo, options.firstTaskRunner ?? execRun).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
2006
+ // A test's runner reads in-process; otherwise in a process of its own (learning-look.ts), so the hardened
2007
+ // git's synchronous reads of the project's folder never hold the console's thread.
2008
+ waits.push((options.firstTaskRunner !== undefined ? findFirstTasks(repo, options.firstTaskRunner) : firstTasksOffThread(repo)).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
1995
2009
  }
1996
2010
  }
1997
2011
  // Learning recovered from reviewed runs (and a project's saved rows carried to its current identity), which
1998
2012
  // Settings → Learning and Knowledge used to do as they opened.
1999
2013
  if (lastChecks.due("learning", 10 * 60_000)) {
2000
2014
  waits.push(lastChecks.run("learning", async () => {
2015
+ // Its reads of each project's Git folder are synchronous, so with a database file they run in a process of
2016
+ // their own (learning-look.ts): a folder that doesn't answer costs its own recovery, never the console. An
2017
+ // in-memory database (a test) can't be shared, so it is read here.
2018
+ if (databaseFile !== null) {
2019
+ await recoverLearningOffThread({ databaseFile, evidenceRoot, repos: managedRepos() }, options.learningRunner === undefined ? {} : { runner: options.learningRunner });
2020
+ return;
2021
+ }
2001
2022
  for (const repo of managedRepos()) {
2002
2023
  try {
2003
2024
  acceptedIdentities(store, repo);
@@ -2012,6 +2033,11 @@ export function createDecisionServer(options) {
2012
2033
  }
2013
2034
  // What Codex has configured, per project, for Settings → Tools to offer.
2014
2035
  for (const repo of managedRepos()) {
2036
+ // The project's own .mcp.json, read off the console's thread (folder-look.ts) for the Tools page and the lead.
2037
+ if (options.identities !== undefined && lastChecks.due(`project-mcp:${repo}`, 10 * 60_000)) {
2038
+ waits.push(lastChecks.run(`project-mcp:${repo}`, async () => { const looked = await lookProjectMcp(repo); if (looked.ok)
2039
+ savedProjectMcp.set(repo, looked.value); return looked.ok; }));
2040
+ }
2015
2041
  if (lastChecks.due(`codex-tools:${repo}`, 10 * 60_000))
2016
2042
  waits.push(lastChecks.run(`codex-tools:${repo}`, () => codexServers(repo)));
2017
2043
  }
@@ -2220,6 +2246,7 @@ export function createDecisionServer(options) {
2220
2246
  get revisionLedgerOf() { return revisionLedgerOf; },
2221
2247
  get firstTasks() { return firstTasks; },
2222
2248
  get authenticateCredential() { return authenticateCredential; },
2249
+ get identities() { return identities; },
2223
2250
  };
2224
2251
  const tasksHandlers = createTasksHandlers(handlerRuntime);
2225
2252
  const pagesHandlers = createPagesHandlers(handlerRuntime);
@@ -43,7 +43,7 @@ import { redirect, respond, SAFETY, taskHref } from "./http.js";
43
43
  import { html } from "../html.js";
44
44
  import {} from "./session.js";
45
45
  export function createFlowsHandlers(runtime) {
46
- const { store, clock, sendScreen, chromeFor, consoleProjects, options, visible, liveCeiling, providerHome, toolHome, restricted, evidenceRoot, authenticateApprover, authenticateCredential, projectOf, unscopedMode, ceiling, authorizeMutation } = runtime;
46
+ const { identities, store, clock, sendScreen, chromeFor, consoleProjects, options, visible, liveCeiling, providerHome, toolHome, restricted, evidenceRoot, authenticateApprover, authenticateCredential, projectOf, unscopedMode, ceiling, authorizeMutation } = runtime;
47
47
  // ---- reads ---------------------------------------------------------------
48
48
  /** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
49
49
  function sendGalleryUse(response, who, template, projects, repo, given, name, notice, said = null) {
@@ -839,8 +839,14 @@ export function createFlowsHandlers(runtime) {
839
839
  if (body.get("repo") !== project || (who.via === "cookie" && body.get("projectRevision") !== String(who.session.projectRevision)))
840
840
  throw new RecipeError("The open project changed. Reopen this recipe in the intended project.", 409);
841
841
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
842
- if (rootMode && !(await authorizedProject(liveCeiling(), project)))
843
- throw new RecipeError("The project is outside this server's access.", 403);
842
+ if (rootMode) {
843
+ // A mutation's target is resolved now, off the console's thread, and must still be the project it names.
844
+ const resolved = await identities.resolveNow(project);
845
+ if (resolved === "no-answer")
846
+ throw new RecipeError("The project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.", 503);
847
+ if (resolved !== project || !(await authorizedProject(liveCeiling(), resolved)))
848
+ throw new RecipeError("The project is outside this server's access.", 403);
849
+ }
844
850
  const rechecked = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
845
851
  if (rechecked !== null)
846
852
  throw new RecipeError(rechecked.message, rechecked.status);
@@ -1,3 +1,4 @@
1
+ import { type ProjectIdentities } from "../project-identity.js";
1
2
  import { type FormFieldOf, type FormView } from "../contracts/console-api.js";
2
3
  import type { TaskFamily } from "../store.js";
3
4
  import { type IncomingMessage, type ServerResponse } from "node:http";
@@ -13,8 +14,11 @@ export interface GuardsRuntime {
13
14
  liveCeiling: () => {
14
15
  repos: string[];
15
16
  roots: readonly string[];
17
+ canon?: (path: string) => string | null;
16
18
  };
17
19
  codingProjects: () => string[];
20
+ /** Project identities read without touching folders (project-identity.ts); absent: the synchronous reads. */
21
+ identities?: ProjectIdentities;
18
22
  unscopedMode: boolean;
19
23
  managedRepos: () => string[];
20
24
  ceiling: import("../project.js").ProjectCeiling;
@@ -1,3 +1,4 @@
1
+ import { syncProjectIdentities } from "../project-identity.js";
1
2
  import { OWNER_ACTIONS, sharedActionPayload } from '../chat-actions.js';
2
3
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
3
4
  import { timingSafeEqual } from "node:crypto";
@@ -14,6 +15,7 @@ import { refuse } from "./chrome.js";
14
15
  import { matchTaskPath } from "./http.js";
15
16
  import { PersistentSessions, SESSION_COOKIE, sessionWithinLifetime } from "./session.js";
16
17
  export function createSharedGuards(runtime) {
18
+ const syncIdentities = syncProjectIdentities();
17
19
  // v99: wrong passwords. One address guessing across names runs out of tries here; each name
18
20
  // locks on its own (sign-in-guard.ts), and every lock, whatever road it came by, is kept.
19
21
  /**
@@ -130,8 +132,9 @@ export function createSharedGuards(runtime) {
130
132
  return fallback();
131
133
  if (Array.isArray(header))
132
134
  return undefined;
133
- const canonical = canonicalProject(header);
134
- if (canonical === null || !visible(canonical))
135
+ // A request's path, never resolved on this thread (project-identity.ts): a known identity or exactly a saved one.
136
+ const canonical = identities().vouched(header, savedIdentities);
137
+ if (canonical === undefined || !visible(canonical))
135
138
  return undefined;
136
139
  return canonical;
137
140
  }
@@ -152,7 +155,8 @@ export function createSharedGuards(runtime) {
152
155
  case 'form-path': {
153
156
  const path = body?.get('path')?.trim() ?? '';
154
157
  // Invalid/missing paths keep the opener's existing 400 response and recoverable form.
155
- return path === '' ? null : canonicalProject(path);
158
+ // Never resolved here: an unknown path passes on to the opener, which resolves it off the thread and checks again.
159
+ return path === '' ? null : identities().vouched(path, savedIdentities) ?? null;
156
160
  }
157
161
  case 'task': {
158
162
  const task = matchTaskPath(url.pathname, '(?:/[a-z-]+)?$');
@@ -370,5 +374,10 @@ export function createSharedGuards(runtime) {
370
374
  function runVisible(run) {
371
375
  return visible(taskRepoOf(run.taskRef));
372
376
  }
377
+ // Defined last, so the reviewed functions above keep their places (password-freshness.test.ts).
378
+ /** Project identities read without touching a folder (project-identity.ts); absent: the synchronous reads. */
379
+ function identities() { return runtime.identities ?? syncIdentities; }
380
+ /** The projects `up` proved and admitted (each its own identity): what a request may name without a read. */
381
+ function savedIdentities() { return runtime.managedRepos(); }
373
382
  return { authenticateApprover, authenticateCredential, authorizeMutation, projectOf, resolveRouteProject, projectRequestAllowed, identify, lookupSession, restricted, visible, admissionList, codingActorAllowed, codingProjectAllowed, workAccess, familyOf, taskRepoOf, runVisible };
374
383
  }
@@ -17,6 +17,9 @@ import { type Store } from "../store.js";
17
17
  import { type SubscriptionLeadRunner } from "../subscription-chat.js";
18
18
  import { type launchRuntimeUpdate } from "../toolroll-update.js";
19
19
  import { type IncomingMessage, type ServerResponse } from "node:http";
20
+ import { type FolderLookRunner } from "../folder-look.js";
21
+ import { type ProjectIdentities } from "../project-identity.js";
22
+ import { type LearningRunner } from "../learning-look.js";
20
23
  export type ServeOptions = {
21
24
  /** Tests: runs a person's command for the HTTP MCP gateway instead of operate.ts's. */
22
25
  runOperateAs?: import("../mcp-person.js").RunOperateAs;
@@ -172,6 +175,14 @@ export type ServeOptions = {
172
175
  * how often they run and ask this computer's agents again. */
173
176
  backgroundChecks?: boolean;
174
177
  backgroundChecksMs?: number;
178
+ /** Tests: how a person's folders are read off the console's thread (folder-look.ts), and how long a read may take. */
179
+ folderLook?: FolderLookRunner;
180
+ folderLookMs?: number;
181
+ /** Project identities resolved off the console's thread (project-identity.ts); `up` and `serve` pass them. Absent:
182
+ * the old synchronous reads (embedders and tests). */
183
+ identities?: ProjectIdentities;
184
+ /** Tests: how the background pass runs one project's learning recovery in a process of its own. */
185
+ learningRunner?: LearningRunner;
175
186
  connectionHome?: string;
176
187
  /** Test seam for Chat's first tasks: the `gh` and `git grep` reads. */
177
188
  firstTaskRunner?: typeof execRun;
@@ -19,6 +19,9 @@ 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 {} from "../folder-look.js";
23
+ import {} from "../project-identity.js";
24
+ import {} from "../learning-look.js";
22
25
  // A note is at most NOTE_BYTE_CAP (16,000) UTF-8 bytes, which URL encoding
23
26
  // can triple: room for one whole, before canonical text validation.
24
27
  export const BODY_CAP = 64 * 1024;
@@ -5,10 +5,11 @@ 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 { dirname, join, resolve } from "node:path";
12
+ import { listFolder, localOrigins } from "../folder-look.js";
12
13
  import { Readable } from "node:stream";
13
14
  import { pipeline } from "node:stream/promises";
14
15
  import { setImmediate as yieldEventLoop } from "node:timers/promises";
@@ -49,13 +50,13 @@ import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRo
49
50
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
50
51
  import { assignmentOf } from "../assignment.js";
51
52
  import { assignmentTaskStatusOf } from "../assignment-presentation.js";
52
- import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
53
+ import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
53
54
  import { capsPage } from "./render-settings.js";
54
55
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
55
56
  import {} from "./session.js";
56
57
  import { rereadBrowser } from "./live.js";
57
58
  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;
59
+ 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
60
  // The filesystem browser (operator request): pick a project folder by
60
61
  // looking, not typing. CONFINED to exactly what opening allows —
61
62
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -67,40 +68,32 @@ export function createPagesHandlers(runtime) {
67
68
  const { url, who, response } = ctx;
68
69
  if (who.via !== "cookie")
69
70
  return refuse(response, who, 403, "browsing feeds a browser session's act");
70
- const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [realpathSync(homedir())] : [];
71
+ const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [identities.hot(homedir())] : [];
71
72
  if (browseRoots.length === 0) {
72
73
  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
74
  }
74
75
  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) {
76
+ const within = (path) => browseRoots.some(root => path === root || path.startsWith(`${root}/`));
77
+ // Spelled outside every root: refused before anything is read. Inside, the look resolves links and checks again.
78
+ if (!within(resolve(asked))) {
79
79
  return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
80
80
  }
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);
81
+ // Read off the console's thread (folder-look.ts): a folder that doesn't answer never freezes the console.
82
+ const looked = await listFolder(asked, browseRoots, { ...(options.folderLook === undefined ? {} : { runner: options.folderLook }), ...(options.folderLookMs === undefined ? {} : { deadlineMs: options.folderLookMs }) });
83
+ if (!looked.ok) {
84
+ return looked.why === "no-answer"
85
+ ? 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")
86
+ : refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
100
87
  }
101
- catch {
102
- return refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
88
+ const canonical = looked.value.at;
89
+ if (canonical === null || !within(canonical)) {
90
+ return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
103
91
  }
92
+ // The look kept only folders inside a root; checked here again, as before.
93
+ const entries = looked.value.entries
94
+ .filter(one => within(one.path))
95
+ .sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
96
+ .slice(0, 200);
104
97
  const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
105
98
  const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
106
99
  const csrf = who.session.csrf;
@@ -133,48 +126,28 @@ export function createPagesHandlers(runtime) {
133
126
  // (GitHub's rule); the FIRST match wins.
134
127
  const localByIdentity = new Map();
135
128
  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
- }
129
+ for (const project of store.listProjects())
130
+ if (visible(project.path))
131
+ registered.add(project.path);
145
132
  // 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
- }
133
+ // (finding 1), at most 400 folders a root. Every read runs off the
134
+ // console's thread (folder-look.ts): a folder that doesn't answer
135
+ // leaves its copies unmatched and the page says so.
136
+ const roots = listed !== null && listed.ok && listed.repos.length > 0 ? [...ceiling.roots] : [];
137
+ let localUnanswered = false;
138
+ if (registered.size > 0 || roots.length > 0) {
139
+ const looked = await localOrigins([...registered], roots, { ...(options.folderLook === undefined ? {} : { runner: options.folderLook }), ...(options.folderLookMs === undefined ? {} : { deadlineMs: options.folderLookMs }) });
140
+ if (looked.ok) {
141
+ const children = looked.value.found.filter(one => roots.some(root => one.path.startsWith(`${root}/`)));
142
+ for (const one of [...looked.value.registered.filter(row => registered.has(row.path)), ...children]) {
143
+ const identity = githubIdentityOf(one.url ?? "");
144
+ if (identity !== null && !localByIdentity.has(identity.toLowerCase()))
145
+ localByIdentity.set(identity.toLowerCase(), one.path);
176
146
  }
177
147
  }
148
+ else {
149
+ localUnanswered = looked.why === "no-answer";
150
+ }
178
151
  }
179
152
  return sendScreen(response, 200, githubReposPage(chromeFor(who.via === "cookie" ? who.session.project : null, "projects"), {
180
153
  listed,
@@ -183,6 +156,7 @@ export function createPagesHandlers(runtime) {
183
156
  csrf: who.via === "cookie" ? who.session.csrf : "",
184
157
  cloneReady: ceiling.roots.length > 0,
185
158
  openProject: who.via === "cookie" ? who.session.project ?? null : null,
159
+ localUnanswered,
186
160
  }));
187
161
  }
188
162
  // List repositories / Refresh list: ask the GitHub CLI signed in on this machine, then show the page.
@@ -354,7 +328,7 @@ export function createPagesHandlers(runtime) {
354
328
  runners: store.listRunners(),
355
329
  worktrees: store
356
330
  .listWorktrees()
357
- .filter(one => project === null || sameRepo(one.repo, project)),
331
+ .filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
358
332
  episode: project === null ? null : store.latestWatchEpisode(project),
359
333
  now,
360
334
  }));
@@ -378,7 +352,7 @@ export function createPagesHandlers(runtime) {
378
352
  }),
379
353
  building: store.liveClaims(project, now),
380
354
  runners: store.listRunners(),
381
- worktrees: store.listWorktrees().filter(one => project === null || sameRepo(one.repo, project)),
355
+ worktrees: store.listWorktrees().filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
382
356
  episode: project === null ? null : store.latestWatchEpisode(project),
383
357
  outboxPending: store.pendingForAttention().length,
384
358
  // External work at a glance (arc 3 finding 24): every dispatch
@@ -665,7 +639,11 @@ export function createPagesHandlers(runtime) {
665
639
  return refuse(response, who, 403, "selecting a project is a browser session's act");
666
640
  }
667
641
  const askedPath = (body.get("path") ?? "").trim();
668
- const canonicalPick = askedPath === "" ? null : canonicalProject(askedPath);
642
+ // Resolved off the console's thread (project-identity.ts): a folder that doesn't answer is said, never waited on.
643
+ const picked = askedPath === "" ? null : await identities.resolveNow(askedPath);
644
+ if (picked === "no-answer")
645
+ 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.");
646
+ const canonicalPick = picked;
669
647
  if ((askedPath !== "" && canonicalPick === null) || (canonicalPick !== null && (!visible(canonicalPick) || !(await authorizedProject(liveCeiling(), canonicalPick))))) {
670
648
  return refuse(response, who, 403, "that project is outside this console's reach");
671
649
  }
@@ -702,7 +680,11 @@ export function createPagesHandlers(runtime) {
702
680
  return refuse(response, who, 403, "opening a project is a browser session's act");
703
681
  }
704
682
  const asked = (body.get("path") ?? "").trim();
705
- const canonical = asked === "" ? null : canonicalProject(asked);
683
+ const resolved = asked === "" ? null : await identities.resolveNow(asked);
684
+ if (resolved === "no-answer") {
685
+ 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")));
686
+ }
687
+ const canonical = resolved;
706
688
  if (canonical === null) {
707
689
  return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
708
690
  }
@@ -807,13 +789,9 @@ export function createPagesHandlers(runtime) {
807
789
  const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
808
790
  if (rootRaw === undefined)
809
791
  return projectsScreen(response, who, "pick one of the configured roots", 400);
810
- let root;
811
- try {
812
- root = realpathSync(rootRaw);
813
- }
814
- catch {
792
+ const root = await identities.resolveNow(rootRaw);
793
+ if (root === null || root === "no-answer")
815
794
  return projectsScreen(response, who, "that projects root does not resolve right now", 400);
816
- }
817
795
  const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
818
796
  if (!previewed.ok)
819
797
  return projectsScreen(response, who, previewed.message, 400);
@@ -886,13 +864,9 @@ export function createPagesHandlers(runtime) {
886
864
  const rootRaw = ceiling.roots[record.rootIndex];
887
865
  if (rootRaw === undefined)
888
866
  return projectsScreen(response, who, "the configured roots changed — preview again", 400);
889
- let root;
890
- try {
891
- root = realpathSync(rootRaw);
892
- }
893
- catch {
867
+ const root = await identities.resolveNow(rootRaw);
868
+ if (root === null || root === "no-answer")
894
869
  return projectsScreen(response, who, "that projects root does not resolve right now", 400);
895
- }
896
870
  if (dirname(record.target) !== root)
897
871
  return projectsScreen(response, who, "the configured roots changed — preview again", 400);
898
872
  const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
@@ -908,8 +882,8 @@ export function createPagesHandlers(runtime) {
908
882
  return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
909
883
  }
910
884
  // 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;
885
+ const clonedAt = await identities.resolveNow(cloned.target);
886
+ const admitted = typeof clonedAt === "string" && clonedAt !== "no-answer" && (await authorizedProject(liveCeiling(), clonedAt)) ? clonedAt : null;
913
887
  if (admitted === null) {
914
888
  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
889
  }
@@ -937,7 +911,7 @@ export function createPagesHandlers(runtime) {
937
911
  const recentPaths = new Set(recent.map(one => one.path));
938
912
  const candidates = new Set();
939
913
  for (const path of consoleProjects()) {
940
- const canonical = canonicalProject(path) ?? path;
914
+ const canonical = identities.hot(path);
941
915
  if (!recentPaths.has(canonical) && visible(canonical))
942
916
  candidates.add(canonical);
943
917
  }