toolroll 0.9.61 → 0.9.62

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/operate.js CHANGED
@@ -19,9 +19,11 @@ import { matchesOutsideCheckpoint } from "./ledger-chain.js";
19
19
  import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
20
20
  import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
21
21
  import { storageSweepSupervisor } from "./storage-sweep-process.js";
22
- import { dailyStorageSweep, lastSweep, nextSweepAt, saveSweep, storageSweepOff as storageSweepOffNow, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
22
+ import { idleStorageSweep, sweepSchedule, sweepScheduleWords } from "./storage-sweep-schedule.js";
23
+ import { lastSweep, saveSweep, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
23
24
  import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
24
25
  import { cleanLandedBacklog, gitOf, landedBacklog, reconcileLanded, BACKLOG_DAYS } from "./landed-cleanup.js";
26
+ import { RemoteMains } from "./remote-main.js";
25
27
  import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
26
28
  import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
27
29
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
@@ -182,6 +184,7 @@ import { createInterface } from "node:readline";
182
184
  import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
183
185
  import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
184
186
  import { WorktreePool } from "./worktree.js";
187
+ import { baseStandingWords, freshBaseFor, taskBaseOf } from "./task-base.js";
185
188
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
186
189
  import { worktreeAdoptionNotice } from "./worktree-notices.js";
187
190
  import { workIndexPage, workIndexTask, WorkIndexCursorError } from "./work-index.js";
@@ -1052,6 +1055,19 @@ function telegramConversation(context, options = {}) {
1052
1055
  phoneOrigin: () => phoneOrigin(process.env, dirname(context.databaseFile), { serverOrigin: options.serverOrigin ?? null }),
1053
1056
  };
1054
1057
  }
1058
+ /** The attempt's history says where it started when that is news: moved to current main, or kept on an older one. */
1059
+ function noteBaseStanding(store, runId, standing, main, now) {
1060
+ if (standing == null)
1061
+ return;
1062
+ const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
1063
+ if (standing.moved !== null) {
1064
+ store.addRunNote(runId, "Toolroll", `Started from current ${main} (${standing.main.slice(0, 8)}). The branch had no commits or changes of its own and was ${commits(standing.moved.behind)} behind at ${standing.moved.from.slice(0, 8)}.`, now);
1065
+ }
1066
+ else if (standing.kept !== null && standing.behind > 0) {
1067
+ const why = standing.kept === "has commits" ? "it has commits of its own" : standing.kept === "has changes" ? "it has uncommitted changes" : "its checkout is not on the task branch";
1068
+ store.addRunNote(runId, "Toolroll", `Based on an older ${main}: ${standing.head.slice(0, 8)} is ${commits(standing.behind)} behind. It was not moved because ${why}.`, now);
1069
+ }
1070
+ }
1055
1071
  /** A revision's verified start: its source build's sealed head, while the revision brief still binds that head and scope. */
1056
1072
  function revisionSourceHead(store, evidenceRoot, taskRef, revisionOf) {
1057
1073
  const source = store.revisionSourceOf(taskRef);
@@ -2728,6 +2744,8 @@ async function tickCommand(flags, context) {
2728
2744
  // A plan branch from an earlier plan (its checkout removed by storage retention) is reused, as its kept checkout was.
2729
2745
  reuseBranch: true,
2730
2746
  reclaim: { evidenceRoot: context.evidenceRoot },
2747
+ // Each plan starts from current main unless it revises earlier work, which keeps that work's head.
2748
+ ...freshBaseFor(store, id, planBranch, base),
2731
2749
  });
2732
2750
  if (!planLeased.ok) {
2733
2751
  release(store, lease, clock());
@@ -2751,6 +2769,7 @@ async function tickCommand(flags, context) {
2751
2769
  now: clock(),
2752
2770
  ...(planStamp === null ? {} : { route: planStamp }),
2753
2771
  });
2772
+ noteBaseStanding(store, planRunId, planLeased.base, base, clock());
2754
2773
  // The source is RECORDED on the attempt before the brief is even
2755
2774
  // composed: the exact bytes the planner reads, sealed as evidence,
2756
2775
  // and the filed scope's digest stamped as what this run was proved
@@ -3054,6 +3073,7 @@ async function tickCommand(flags, context) {
3054
3073
  continue;
3055
3074
  }
3056
3075
  }
3076
+ // An existing branch with no commits of its own starts from current main (a new one already does).
3057
3077
  const leased = await worktrees.lease({
3058
3078
  repo,
3059
3079
  branch,
@@ -3062,6 +3082,7 @@ async function tickCommand(flags, context) {
3062
3082
  now: clock(),
3063
3083
  ...(exists.code === 0 ? {} : { base: buildBase }),
3064
3084
  reclaim: { evidenceRoot: context.evidenceRoot },
3085
+ ...(exists.code === 0 ? freshBaseFor(store, id, branch, base) : {}),
3065
3086
  });
3066
3087
  if (!leased.ok) {
3067
3088
  // The task is fine; this machine's pool is not. Hand the claim back so
@@ -3122,6 +3143,7 @@ async function tickCommand(flags, context) {
3122
3143
  dispatched.push({ id, outcome: "skipped", reason: "admission-refused", detail: error instanceof Error ? error.message : String(error) });
3123
3144
  continue;
3124
3145
  }
3146
+ noteBaseStanding(store, runId, leased.base, base, clock());
3125
3147
  if (leased.resumedFromRun !== undefined) {
3126
3148
  store.addRunNote(runId, "Toolroll", `Recovered the ${leased.recoveryKind === "completed" ? "completed source draft" : "work-in-progress draft"} from interrupted attempt #${leased.resumedFromRun}. This fresh attempt is reviewing and verifying it; the safety patch is retained.`, clock());
3127
3149
  }
@@ -3448,10 +3470,15 @@ async function reconcileCommand(flags, context) {
3448
3470
  // Landed work (landed-cleanup.ts): finish teardowns a crash cut short, then mark newly landed results complete (a
3449
3471
  // ledger entry says why) and remove their clean checkouts and local build and plan branches.
3450
3472
  let landed = { completed: [], steps: [] };
3473
+ // Completion trusts only the recorded remote's main, fetched by this process into a private folder it removes after.
3474
+ const remotes = new RemoteMains(store, context.gitRunner, context.evidenceRoot);
3451
3475
  try {
3452
- landed = await reconcileLanded(store, worktrees, gitOf(context.gitRunner), repo, context.evidenceRoot, clock);
3476
+ landed = await reconcileLanded(store, worktrees, gitOf(context.gitRunner), remotes, repo, context.evidenceRoot, clock);
3453
3477
  }
3454
3478
  catch { /* the next pass tries again */ }
3479
+ finally {
3480
+ remotes.close();
3481
+ }
3455
3482
  const landedGone = landed.steps.filter(one => one.outcome === "removed");
3456
3483
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
3457
3484
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
@@ -3469,15 +3496,19 @@ async function reconcileCommand(flags, context) {
3469
3496
  retention = null; /* the next pass tries again */
3470
3497
  }
3471
3498
  const retained = retention === null ? [] : retention.counts.filter(one => one.count > 0);
3472
- // The daily storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
3499
+ // The storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
3473
3500
  // checkouts, extra staged runtimes and dependency installs unused for a week; saved for Settings → Storage.
3501
+ // Only the long-lived service (its watch passes `sweepOwner`) starts one, once due and while nothing is running or
3502
+ // waiting (storage-sweep-schedule.ts). A one-shot reconcile would exit and lose the claimed sweep, so it leaves it.
3474
3503
  // Launch only: report a result here only if it already completed. Later results save independently.
3475
3504
  let storageSwept = null;
3476
- try {
3477
- storageSwept = dailyStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots(), ...(context.sweepOwner === undefined ? {} : { owner: context.sweepOwner }) }, clock);
3478
- }
3479
- catch {
3480
- storageSwept = null; /* the next pass tries again */
3505
+ if (context.sweepOwner !== undefined) {
3506
+ try {
3507
+ storageSwept = idleStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots(), owner: context.sweepOwner }, clock);
3508
+ }
3509
+ catch {
3510
+ storageSwept = null; /* the next pass tries again */
3511
+ }
3481
3512
  }
3482
3513
  const swept = storageSwept === null ? [] : storageSwept.parts.filter(one => one.count > 0);
3483
3514
  const nothing = retained.length === 0 &&
@@ -6023,7 +6054,8 @@ async function runWatchLoop(args) {
6023
6054
  if (!Number.isSafeInteger(reconcileEveryMs) || reconcileEveryMs < 1 || reconcileEveryMs > 2_147_483_647) {
6024
6055
  throw new Error("--reconcile-every must be an integer from 1 to 2147483647 milliseconds");
6025
6056
  }
6026
- const incarnation = randomUUID();
6057
+ // The owner carries lifetime without changing the lease schema. Bounded watches cannot host automatic sweeps.
6058
+ const incarnation = `${runFor === null ? "toolroll-service:" : ""}${randomUUID()}`;
6027
6059
  const lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
6028
6060
  if (!lease.ok) {
6029
6061
  return {
@@ -6158,7 +6190,9 @@ async function runWatchLoop(args) {
6158
6190
  },
6159
6191
  run: async () => {
6160
6192
  const maintenanceLines = [];
6161
- const maintenanceContext = { ...quietContext, sweepOwner, write: line => maintenanceLines.push(line) };
6193
+ const maintenanceContext = { ...quietContext, ...(runFor === null ? { sweepOwner } : {}), write: line => maintenanceLines.push(line) };
6194
+ if (runFor !== null)
6195
+ delete maintenanceContext.sweepOwner;
6162
6196
  const code = await reconcileCommand(passFlags(), maintenanceContext);
6163
6197
  if (code !== EXIT.ok)
6164
6198
  throw new Error(maintenanceLines.join(" "));
@@ -7212,6 +7246,9 @@ async function upCommand(flags, context) {
7212
7246
  }
7213
7247
  const loopFlagsFor = (repo) => {
7214
7248
  const copy = new Map();
7249
+ // A bounded `up` must not advertise its child watches as an unbounded sweep service either.
7250
+ if (forGiven !== undefined)
7251
+ copy.set("for", forGiven);
7215
7252
  copy.set("runner", runnerName);
7216
7253
  copy.set("token", runnerToken);
7217
7254
  copy.set("repo", repo);
@@ -9054,6 +9091,7 @@ function showTask(positional, context) {
9054
9091
  const shared = listed === null ? null : { headline: listed.status.label, sentence: listed.status.detail };
9055
9092
  const primaryAction = listed === null ? work?.primaryAction ?? null : listed.primaryAction;
9056
9093
  const steeringLive = store.steeringLiveBuild(ref.id, now) !== null;
9094
+ const base = taskBaseOf(store, id);
9057
9095
  const detail = {
9058
9096
  task,
9059
9097
  work: work === null || listed === null ? work : { ...work, status: { ...work.status, label: listed.status.label, detail: listed.status.detail, tone: listed.status.tone }, primaryAction },
@@ -9069,6 +9107,8 @@ function showTask(positional, context) {
9069
9107
  scope,
9070
9108
  approval: approvalOf(scope),
9071
9109
  route: taskRouteView(routed, readiness),
9110
+ // Fresh base: the commit this task's work starts from, and how far current main has moved past it.
9111
+ base,
9072
9112
  runs: runs.map(one => ({ ...one, route: store.runRoute(one.id) })),
9073
9113
  deliverable: ref.deliverable,
9074
9114
  report: readVerifiedReport(store, context.evidenceRoot, ref.id),
@@ -9125,6 +9165,7 @@ function showTask(positional, context) {
9125
9165
  ]
9126
9166
  : [` report: ${detail.report.problem} (run ${detail.report.run})`]),
9127
9167
  ...pullRequestLines(detail.pullRequest),
9168
+ ...(base === null ? [] : [` base: ${baseStandingWords(base)}`]),
9128
9169
  ...(detail.blockedBy.length > 0 ? [` waits for ${detail.blockedBy.join(", ")}`] : []),
9129
9170
  ...(detail.position === null
9130
9171
  ? []
@@ -11165,15 +11206,15 @@ async function storageCommand(positional, flags, context) {
11165
11206
  const sweepFacts = () => {
11166
11207
  const last = lastSweep(store);
11167
11208
  const automatic = last?.source === "automatic" ? last : lastSweep(store, "automatic");
11168
- return { last, lastAutomatic: automatic, next: nextSweepAt(store) };
11209
+ return { last, lastAutomatic: automatic, schedule: sweepSchedule(store, context.clock()) };
11169
11210
  };
11170
11211
  const sweepLines = (facts) => {
11171
11212
  const when = (at) => new Date(at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
11172
11213
  const one = facts.last;
11173
- const head = one === null ? (facts.next === null && storageSweepOffNow() ? "Automatic sweep: off." : "Automatic sweep: not run yet; it runs once a day.")
11214
+ const head = one === null ? "Not swept yet."
11174
11215
  : `Last swept ${when(one.at)}${one.source === "manual" ? ` by ${one.actor}` : " automatically"}: ${storageSweepWords(one.parts)}`;
11175
11216
  const auto = one !== null && one.source === "manual" && facts.lastAutomatic !== null ? [`Last automatic sweep ${when(facts.lastAutomatic.at)}: ${storageSweepWords(facts.lastAutomatic.parts)}`] : [];
11176
- return [head, ...auto, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
11217
+ return [head, ...auto, `Automatic sweep: ${sweepScheduleWords(facts.schedule, when)}`, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
11177
11218
  };
11178
11219
  if (action === undefined) {
11179
11220
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
@@ -11289,6 +11330,16 @@ async function storageCommand(positional, flags, context) {
11289
11330
  * checkouts and local branches go; so do Toolroll's own branches no task uses whose work landed. A preview changes
11290
11331
  * nothing; --yes takes an instance operator, records every teardown first, then carries it out. */
11291
11332
  async function landedCommand(yes, operator, refused, pool, context) {
11333
+ // Completion trusts only the recorded remote's main, fetched by this process into a private folder it removes after.
11334
+ const remotes = new RemoteMains(context.store, context.gitRunner, context.evidenceRoot);
11335
+ try {
11336
+ return await landedCommandWith(remotes, yes, operator, refused, pool, context);
11337
+ }
11338
+ finally {
11339
+ remotes.close();
11340
+ }
11341
+ }
11342
+ async function landedCommandWith(remotes, yes, operator, refused, pool, context) {
11292
11343
  const command = "storage clean --landed";
11293
11344
  const git = gitOf(context.gitRunner);
11294
11345
  const plurals = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
@@ -11300,7 +11351,7 @@ async function landedCommand(yes, operator, refused, pool, context) {
11300
11351
  branches: plan.results.reduce((sum, one) => sum + one.targets.filter(target => target.kind === "branch").length, 0) + plan.branches.length,
11301
11352
  });
11302
11353
  if (!yes) {
11303
- const plan = await landedBacklog(context.store, git, context.evidenceRoot, context.clock());
11354
+ const plan = await landedBacklog(context.store, git, remotes, context.evidenceRoot, context.clock());
11304
11355
  const n = counts(plan);
11305
11356
  const anything = n.results + plan.branches.length + plan.pending > 0;
11306
11357
  return succeed(context.write, context.json, command, { preview: true, cutoff: plan.cutoff, results: plan.results, branches: plan.branches, kept: plan.kept, pending: plan.pending }, () => [
@@ -11315,8 +11366,8 @@ async function landedCommand(yes, operator, refused, pool, context) {
11315
11366
  const actor = await operator();
11316
11367
  if (actor === null)
11317
11368
  return refused("cleans up landed work");
11318
- const plan = await landedBacklog(context.store, git, context.evidenceRoot, context.clock());
11319
- const done = await cleanLandedBacklog(context.store, pool, git, context.evidenceRoot, context.clock, plan, actor);
11369
+ const plan = await landedBacklog(context.store, git, remotes, context.evidenceRoot, context.clock());
11370
+ const done = await cleanLandedBacklog(context.store, pool, git, remotes, context.evidenceRoot, context.clock, plan, actor);
11320
11371
  const gone = done.steps.filter(one => one.outcome === "removed" || one.outcome === "already gone");
11321
11372
  const stays = done.steps.filter(one => one.outcome === "waits" || one.outcome === "kept");
11322
11373
  return succeed(context.write, context.json, command, { completed: done.completed, steps: done.steps, refused: done.refused, kept: plan.kept }, () => [
@@ -24,11 +24,16 @@ export type AdmissionFailure = {
24
24
  reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed";
25
25
  message: string;
26
26
  };
27
+ /** `remote`: the origin URL this add recorded as the project's trusted remote (null: none), and why one wasn't. */
27
28
  export type Admitted = {
28
29
  ok: true;
29
30
  repo: string;
30
31
  name: string;
31
32
  added: boolean;
33
+ remote: {
34
+ url: string | null;
35
+ why: string | null;
36
+ };
32
37
  };
33
38
  export type Released = {
34
39
  ok: true;
@@ -43,12 +48,7 @@ export declare function proveProjectRoot(path: string, exec?: ProjectExec): Prom
43
48
  ok: true;
44
49
  repo: string;
45
50
  } | AdmissionFailure>;
46
- /**
47
- * Admit one project. Idempotent: adding a saved project again changes
48
- * nothing and writes no ledger row. The registry is written first, under
49
- * its lock; the project row and its ledger row then commit together, so a
50
- * retry after a failure finishes the job and records it exactly once.
51
- */
51
+ /** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
52
52
  export declare function admitProject(store: Store, input: {
53
53
  /** null: a console with no machine registry (a bare `serve`) keeps only the project list. */
54
54
  registryFile: string | null;
@@ -56,6 +56,10 @@ export declare function admitProject(store: Store, input: {
56
56
  actor: AdmissionActor;
57
57
  now: Date;
58
58
  exec?: ProjectExec;
59
+ /** Only the authorized CLI add opts in; console/open/onboarding never discover a remote. */
60
+ captureOrigin?: boolean;
61
+ /** Explicit value from the owner/instance-operator CLI boundary. */
62
+ remoteUrl?: string;
59
63
  }): Promise<Admitted | AdmissionFailure>;
60
64
  /**
61
65
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -15,7 +15,12 @@ import { userInfo } from "node:os";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { run } from "./exec.js";
17
17
  import { canonicalProject, projectName, sameRepo } from "./project.js";
18
+ import { remoteUrlProblem } from "./remote-main.js";
18
19
  import { addRepos, loadProjectRegistry, removeRepos, updateRepos } from "./repos.js";
20
+ const ADMISSION_GIT = {
21
+ envAllowlist: ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"],
22
+ env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1" },
23
+ };
19
24
  export const PROJECT_ADDED = "project added";
20
25
  export const PROJECT_REMOVED = "project removed";
21
26
  /** The checkout root a folder belongs to, canonical; never a shell. */
@@ -26,40 +31,57 @@ export async function proveProjectRoot(path, exec = run) {
26
31
  const canonical = canonicalProject(path);
27
32
  if (canonical === null)
28
33
  return { ok: false, reason: "unavailable", message: `${path} isn't a folder on this computer.` };
29
- const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000 });
34
+ const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000, ...ADMISSION_GIT });
30
35
  const repo = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
31
36
  if (repo === null)
32
37
  return { ok: false, reason: "not-git", message: `${path} isn't a Git repository. Run git init there first, or choose a folder inside one.` };
33
38
  return { ok: true, repo };
34
39
  }
35
- /**
36
- * Admit one project. Idempotent: adding a saved project again changes
37
- * nothing and writes no ledger row. The registry is written first, under
38
- * its lock; the project row and its ledger row then commit together, so a
39
- * retry after a failure finishes the job and records it exactly once.
40
- */
40
+ /** Read only on first CLI admission; never rediscover trust from an existing project's config. */
41
+ async function originOf(repo, exec) {
42
+ const read = await exec("git", ["config", "--local", "--no-includes", "--get", "remote.origin.url"], { cwd: repo, timeoutMs: 5_000, ...ADMISSION_GIT });
43
+ const url = read.code === 0 ? read.stdout.trim() : "";
44
+ if (url === "")
45
+ return { url: null, why: "it has no origin remote" };
46
+ const problem = remoteUrlProblem(url);
47
+ return problem === null ? { url, why: null } : { url: null, why: problem };
48
+ }
49
+ /** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
41
50
  export async function admitProject(store, input) {
42
51
  const proved = await proveProjectRoot(input.path, input.exec);
43
52
  if (!proved.ok)
44
53
  return proved;
45
54
  const { repo } = proved;
55
+ if (input.remoteUrl !== undefined && (input.actor.origin === "console" || remoteUrlProblem(input.remoteUrl) !== null)) {
56
+ return { ok: false, reason: "failed", message: "Only an owner or instance operator can record a network remote with repos add --remote <url>. Use an HTTPS or SSH URL without credentials." };
57
+ }
58
+ const before = input.registryFile === null ? null : await loadProjectRegistry(input.registryFile);
59
+ const known = store.projectAdmissionKnown(repo) || (before !== null && !("error" in before) && before.repos.includes(repo));
60
+ const discovered = !known && input.captureOrigin === true && input.actor.origin !== "console"
61
+ ? await originOf(repo, input.exec ?? run) : null;
62
+ let remote = { url: store.projectRemote(repo), why: null };
46
63
  let registryHad = true;
47
64
  if (input.registryFile !== null) {
48
- const before = await loadProjectRegistry(input.registryFile);
49
65
  const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
50
66
  if (!enrolled.ok)
51
67
  return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
52
- registryHad = !("error" in before) && before.repos.includes(repo);
68
+ registryHad = before !== null && !("error" in before) && before.repos.includes(repo);
53
69
  }
54
70
  const added = store.transact(() => {
55
71
  const rowHad = store.listProjects().some(one => one.path === repo);
56
- store.upsertProject(repo, projectName(repo), input.now);
72
+ const previous = store.projectRemote(repo);
73
+ const first = !known && !store.projectAdmissionKnown(repo);
74
+ remote = input.remoteUrl !== undefined ? { url: input.remoteUrl, why: null }
75
+ : first && discovered !== null ? discovered : { url: previous, why: previous === null ? "no remote recorded; use repos add --remote <url>" : null };
76
+ store.upsertProject(repo, projectName(repo), input.now, { remoteUrl: remote.url });
77
+ if (input.remoteUrl !== undefined)
78
+ store.recordProjectRemote(input.actor.label, repo, previous, input.remoteUrl, input.now);
57
79
  if (rowHad && registryHad)
58
80
  return false;
59
81
  store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_ADDED, outcome: "added", source: "access", detail: `from ${input.actor.origin}` });
60
82
  return true;
61
83
  });
62
- return { ok: true, repo, name: projectName(repo), added };
84
+ return { ok: true, repo, name: projectName(repo), added, remote };
63
85
  }
64
86
  /**
65
87
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -8,6 +8,7 @@ import { type Html } from "./html.js";
8
8
  import { type ProjectHoldings } from "./project-delete.js";
9
9
  export declare const PROJECT_DELETE_CSS: string;
10
10
  export type ProjectSettingsView = {
11
+ remoteUrl?: string | null;
11
12
  repo: string;
12
13
  name: string;
13
14
  holdings: ProjectHoldings;
@@ -57,7 +57,10 @@ const note = (notice) => notice.problem ? html `<p class="problem" role="alert">
57
57
  /** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
58
58
  export function projectSettingsHtml(view, notice) {
59
59
  const held = html `<p>Toolroll holds ${holdingsWords(view.holdings)} for <strong>${view.name}</strong>.</p>`;
60
- const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${buildsHtml(view)}${accessHtml(view)}`;
60
+ const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
61
+ ? html `<p class="path">${view.remoteUrl}</p>`
62
+ : html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add &lt;project&gt; --remote &lt;url&gt;</p></details></section>`;
63
+ const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}`;
61
64
  if (!view.canDelete)
62
65
  return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
63
66
  const body = view.running.length > 0
@@ -0,0 +1,57 @@
1
+ import { type ExecResult } from "./exec.js";
2
+ import type { Store } from "./store.js";
3
+ import type { Runner } from "./worktree.js";
4
+ /** The only credential helper https may use: the system keychain on macOS; elsewhere none, so a private https remote
5
+ * fails closed instead of asking. */
6
+ export declare const CREDENTIAL_HELPER: string;
7
+ export type RemoteUrl = {
8
+ kind: "network";
9
+ url: string;
10
+ };
11
+ /**
12
+ * A URL Toolroll may fetch remote main from: https or ssh (either form). Local paths, file URLs and loopback hosts
13
+ * (this machine is where builds run) are always refused. Remote helpers (ext::, fd:: and the like), plain http and
14
+ * git:// (no authentication), relative paths, embedded passwords and anything odd are refused.
15
+ */
16
+ export declare function parseRemoteUrl(url: string): RemoteUrl | {
17
+ why: string;
18
+ };
19
+ /** Why a recorded URL may not be used as the trusted remote; null when it may. */
20
+ export declare function remoteUrlProblem(url: string): string | null;
21
+ export type RemoteGit = (args: readonly string[]) => Promise<ExecResult>;
22
+ export type RemoteMain = {
23
+ url: string;
24
+ /** The commit the recorded remote's main is at, as fetched just now. */
25
+ main: string;
26
+ /** Git in the private directory, hardened as above. */
27
+ git: RemoteGit;
28
+ /** Bring this exact commit (with its history) from the project by fetch, for a content comparison. */
29
+ bring: (repo: string, head: string) => Promise<boolean>;
30
+ };
31
+ export type RemoteMainRead = {
32
+ ok: true;
33
+ remote: RemoteMain;
34
+ } | {
35
+ ok: false;
36
+ why: string;
37
+ };
38
+ /**
39
+ * One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
40
+ * the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
41
+ * changed. Close removes every directory.
42
+ */
43
+ export declare class RemoteMains {
44
+ private readonly store;
45
+ private readonly runner;
46
+ private readonly scratch;
47
+ private readonly helper;
48
+ private readonly dirs;
49
+ constructor(store: Store, runner: Runner | undefined, scratch: string, helper?: string);
50
+ private exec;
51
+ /** Nothing but fetched objects decides: a private directory that grew alternates, grafts or shallow state is
52
+ * not trusted. */
53
+ private clean;
54
+ private dirFor;
55
+ read(repo: string): Promise<RemoteMainRead>;
56
+ close(): void;
57
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The project's main as its recorded remote has it, read by Toolroll's own process. Automatic completion of landed
3
+ * work (landed-cleanup.ts) trusts only this: a build shares the project's .git, so it can move the local main or
4
+ * repoint origin in .git/config, and neither may complete its own task.
5
+ *
6
+ * The remote URL is the one captured on first admission or explicitly supplied by the owner (project-admission.ts), never the repository's
7
+ * config. Each read fetches that URL's main into a fresh, private Git directory under Toolroll's evidence folder (which
8
+ * the agent fence denies to builds), with no system or global config, no includes or URL rewriting, no replace
9
+ * objects, no hooks, no ext or file transport and only an explicit credential helper. The shared repository's objects, refs,
10
+ * replace refs, grafts and shallow state are never read: commits enter only by fetch. A content comparison brings the
11
+ * result's exact recorded commit in by fetch too; git checks every object against its hash.
12
+ *
13
+ * Every doubt fails closed: no recorded remote, a URL that isn't allowed, a failed fetch or a remote without main all
14
+ * read as "not proven", and the task stays for a person.
15
+ */
16
+ import { mkdirSync, mkdtempSync, rmSync, existsSync } from "node:fs";
17
+ import { isAbsolute, join } from "node:path";
18
+ import { run } from "./exec.js";
19
+ /** The ref a fetched remote main is kept under, inside the private directory only. */
20
+ const REMOTE_MAIN = "refs/toolroll/remote-main";
21
+ const SHA = /^[0-9a-f]{40}$/;
22
+ const FETCH_TIMEOUT_MS = 120_000;
23
+ /** What the private directory's git may see of this process's environment: nothing that names a repository, an
24
+ * object store or config. The fixed settings below go on top. */
25
+ const ENV_ALLOWLIST = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SSH_AUTH_SOCK", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"];
26
+ const ENV = {
27
+ GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_TERMINAL_PROMPT: "0",
28
+ GIT_ASKPASS: "", SSH_ASKPASS: "", GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
29
+ };
30
+ /** The only credential helper https may use: the system keychain on macOS; elsewhere none, so a private https remote
31
+ * fails closed instead of asking. */
32
+ export const CREDENTIAL_HELPER = process.platform === "darwin" ? "osxkeychain" : "";
33
+ /** Settings for every command in the private directory. The directory's own config is ours and empty. File transport
34
+ * is off for the recorded remote; only the result fetch from the registered project path turns it on. */
35
+ function hardened(helper, file = "never") {
36
+ return ["-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always",
37
+ "-c", `protocol.file.allow=${file}`, "-c", "protocol.ext.allow=never", "-c", "credential.helper=", ...(helper === "" ? [] : ["-c", `credential.helper=${helper}`]),
38
+ "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "fetch.recurseSubmodules=false", "-c", "core.fsmonitor=false"];
39
+ }
40
+ /**
41
+ * A URL Toolroll may fetch remote main from: https or ssh (either form). Local paths, file URLs and loopback hosts
42
+ * (this machine is where builds run) are always refused. Remote helpers (ext::, fd:: and the like), plain http and
43
+ * git:// (no authentication), relative paths, embedded passwords and anything odd are refused.
44
+ */
45
+ export function parseRemoteUrl(url) {
46
+ const refused = { why: "its remote URL isn't one Toolroll fetches from" };
47
+ if (url === "" || url.length > 2048 || /[\x00-\x20\x7f]/.test(url) || url.startsWith("-") || url.includes("\\"))
48
+ return refused;
49
+ if (/^[A-Za-z][A-Za-z0-9+.-]*::/.test(url) || /^file:/i.test(url) || /^[A-Za-z]:[\\/]/.test(url))
50
+ return refused;
51
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url)?.[1]?.toLowerCase();
52
+ if (scheme !== undefined) {
53
+ if (scheme !== "https" && scheme !== "ssh")
54
+ return refused;
55
+ try {
56
+ const parsed = new URL(url);
57
+ if (parsed.password !== "" || (scheme === "https" && parsed.username !== "") || parsed.search !== "" || parsed.hash !== "" || parsed.hostname === "" || loopback(parsed.hostname))
58
+ return refused;
59
+ }
60
+ catch {
61
+ return refused;
62
+ }
63
+ return { kind: "network", url };
64
+ }
65
+ // scp-like ssh: [user@]host:path, with no slash before the first colon.
66
+ const colon = url.indexOf(":");
67
+ const host = /^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+)$/.exec(url.slice(0, colon))?.[1];
68
+ if (colon > 0 && colon < url.length - 1 && host !== undefined && !loopback(host))
69
+ return { kind: "network", url };
70
+ return refused;
71
+ }
72
+ /** localhost (and its subdomains), any IPv4 spelling of 127.0.0.0/8 or 0.0.0.0 (inet_aton accepts 127.1, 0x7f.1 and
73
+ * the like), and ::1, :: or an IPv4-mapped loopback. */
74
+ function loopback(hostname) {
75
+ const host = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.+$/, "");
76
+ if (host === "localhost" || host.endsWith(".localhost"))
77
+ return true;
78
+ const parts = host.split(".");
79
+ if (parts.length <= 4 && parts.every(part => /^(0x[0-9a-f]*|[0-9]+)$/.test(part))) {
80
+ const values = parts.map(part => part.startsWith("0x") ? parseInt(part.slice(2) || "0", 16) : /^0[0-7]+$/.test(part) ? parseInt(part, 8) : Number(part));
81
+ const first = values.length === 1 ? Math.floor(values[0] / 2 ** 24) : values[0];
82
+ return first === 127 || values.every(value => value === 0);
83
+ }
84
+ if (!host.includes(":"))
85
+ return false;
86
+ try {
87
+ const v6 = new URL(`http://[${host}]/`).hostname;
88
+ return v6 === "[::1]" || v6 === "[::]" || /^\[::ffff:(7f[0-9a-f]{2}:|0:)/.test(v6);
89
+ }
90
+ catch {
91
+ return true;
92
+ }
93
+ }
94
+ /** Why a recorded URL may not be used as the trusted remote; null when it may. */
95
+ export function remoteUrlProblem(url) {
96
+ const parsed = parseRemoteUrl(url);
97
+ return "why" in parsed ? parsed.why : null;
98
+ }
99
+ /**
100
+ * One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
101
+ * the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
102
+ * changed. Close removes every directory.
103
+ */
104
+ export class RemoteMains {
105
+ store;
106
+ runner;
107
+ scratch;
108
+ helper;
109
+ dirs = new Map();
110
+ constructor(store, runner = run, scratch, helper = CREDENTIAL_HELPER) {
111
+ this.store = store;
112
+ this.runner = runner;
113
+ this.scratch = scratch;
114
+ this.helper = helper;
115
+ }
116
+ exec(dir, args, file = "never") {
117
+ return this.runner("git", ["--git-dir", dir, ...hardened(this.helper, file), ...args], { cwd: dir, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: FETCH_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
118
+ }
119
+ /** Nothing but fetched objects decides: a private directory that grew alternates, grafts or shallow state is
120
+ * not trusted. */
121
+ clean(dir) {
122
+ return !["shallow", join("info", "grafts"), join("objects", "info", "alternates"), join("objects", "info", "http-alternates")].some(name => existsSync(join(dir, name)));
123
+ }
124
+ async dirFor(repo) {
125
+ const known = this.dirs.get(repo);
126
+ if (known !== undefined && existsSync(known))
127
+ return known;
128
+ try {
129
+ mkdirSync(this.scratch, { recursive: true });
130
+ const dir = mkdtempSync(join(this.scratch, ".remote-main-"));
131
+ this.dirs.set(repo, dir);
132
+ const made = await this.runner("git", ["init", "--quiet", "--bare", "--template=", dir], { cwd: dir, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: 30_000 });
133
+ return made.code === 0 ? dir : null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ async read(repo) {
140
+ const url = this.store.projectRemote(repo);
141
+ if (url === null)
142
+ return { ok: false, why: "the project has no recorded remote" };
143
+ const problem = remoteUrlProblem(url);
144
+ if (problem !== null)
145
+ return { ok: false, why: problem };
146
+ const dir = await this.dirFor(repo);
147
+ if (dir === null)
148
+ return { ok: false, why: "Toolroll couldn't read the project's remote main" };
149
+ const fetched = await this.exec(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", url, `+refs/heads/main:${REMOTE_MAIN}`]);
150
+ if (fetched.code !== 0 || !this.clean(dir))
151
+ return { ok: false, why: "Toolroll couldn't read the project's remote main" };
152
+ const resolved = await this.exec(dir, ["rev-parse", "--verify", "--quiet", `${REMOTE_MAIN}^{commit}`]);
153
+ const main = resolved.stdout.trim();
154
+ if (resolved.code !== 0 || !SHA.test(main))
155
+ return { ok: false, why: "Toolroll couldn't read the project's remote main" };
156
+ const git = args => this.exec(dir, args);
157
+ const bring = async (from, head) => {
158
+ if (!SHA.test(head))
159
+ return false;
160
+ if ((await git(["cat-file", "-e", `${head}^{commit}`])).code === 0)
161
+ return true;
162
+ // The one local fetch: from the project path Toolroll registered (never a URL), for this exact hash-checked commit.
163
+ if (!isAbsolute(from))
164
+ return false;
165
+ const got = await this.exec(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", from, `+${head}:refs/toolroll/results/${head}`], "always");
166
+ return got.code === 0 && this.clean(dir) && (await git(["cat-file", "-e", `${head}^{commit}`])).code === 0;
167
+ };
168
+ return { ok: true, remote: { url, main, git, bring } };
169
+ }
170
+ close() {
171
+ for (const dir of this.dirs.values()) {
172
+ try {
173
+ rmSync(dir, { recursive: true, force: true });
174
+ }
175
+ catch { /* the next pass makes its own */ }
176
+ }
177
+ this.dirs.clear();
178
+ }
179
+ }
@@ -3,6 +3,7 @@ import { type Html } from "../html.js";
3
3
  import { type RunActivity } from "../activity-line.js";
4
4
  import { type AgentChoice } from "../agentconfig.js";
5
5
  import { type AssignmentSnapshot } from "../assignment.js";
6
+ import { type TaskBaseView } from "../task-base.js";
6
7
  import { type BrowserTaskView } from "../browser-workspace.js";
7
8
  import { type FormView } from "../contracts/console-api.js";
8
9
  import { type DispatchDiagnosis } from "../dispatch.js";
@@ -588,6 +589,8 @@ export declare function taskBodyParts(data: {
588
589
  /** The pull request opened through Complete (its state, link, CI and merge), or the offer to open one for a
589
590
  * result already marked complete; null when neither applies. */
590
591
  pullRequest?: TaskPullRequest | null;
592
+ /** Fresh base: the commit this task's work starts from, and how far current main has moved past it. */
593
+ base?: TaskBaseView | null;
591
594
  /** The task's whole family (the original and its revisions) and every attempt across it, for the thread. */
592
595
  family?: {
593
596
  root: {