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/CHANGELOG.md +14 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +107 -0
- package/dist/folder-look.js +302 -0
- 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/operate.d.ts +32 -0
- package/dist/operate.js +186 -28
- package/dist/project-identity.d.ts +37 -0
- package/dist/project-identity.js +106 -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 +35 -8
- package/dist/server/flows.js +9 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.js +3 -0
- package/dist/server/pages.js +62 -88
- package/dist/server/render-pages.d.ts +2 -16
- package/dist/server/render-pages.js +1 -55
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.js +14 -3
- package/dist/server/tasks.js +7 -3
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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);
|
package/dist/server/flows.js
CHANGED
|
@@ -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
|
|
843
|
-
|
|
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);
|
package/dist/server/guards.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/guards.js
CHANGED
|
@@ -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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/server/http.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/http.js
CHANGED
|
@@ -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;
|
package/dist/server/pages.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 ? [
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
102
|
-
|
|
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 (
|
|
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
|
-
}
|
|
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),
|
|
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
|
-
}
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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
|
|
912
|
-
const admitted =
|
|
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 =
|
|
914
|
+
const canonical = identities.hot(path);
|
|
941
915
|
if (!recentPaths.has(canonical) && visible(canonical))
|
|
942
916
|
candidates.add(canonical);
|
|
943
917
|
}
|