toolroll 0.9.75 → 0.9.76
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 +49 -0
- package/dist/agent-fence.js +8 -0
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/batch-checks.js +3 -1
- package/dist/builder.js +1 -1
- package/dist/coding-workspace.js +7 -5
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/first-run.js +3 -1
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +3 -1
- package/dist/operate.js +7 -1
- package/dist/planner.js +5 -1
- package/dist/project-knowledge.js +1 -1
- package/dist/project-learning.js +22 -2
- package/dist/pull-request-flow.js +4 -0
- package/dist/release-adapters.d.ts +24 -2
- package/dist/release-adapters.js +136 -10
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +64 -0
- package/dist/release-auto.d.ts +153 -0
- package/dist/release-auto.js +496 -0
- package/dist/release-cli.js +56 -2
- package/dist/release-coverage.js +1 -1
- package/dist/release.d.ts +105 -1
- package/dist/release.js +229 -9
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +44 -3
- package/dist/scout.js +5 -1
- package/dist/server/render-tasks.d.ts +10 -0
- package/dist/server/render-tasks.js +23 -13
- package/dist/server/route-table.js +3 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +8 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.js +19 -4
- package/dist/task-base.js +1 -1
- package/dist/teardown-git.d.ts +38 -10
- package/dist/teardown-git.js +894 -108
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.76 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
- **Releases can approve themselves when they're green.** A new per-project
|
|
6
|
+
setting, **Release approval: Ask me / Approve automatically when checks
|
|
7
|
+
pass**, is off by default. Turning it on takes your typed password;
|
|
8
|
+
turning it off takes one click and withdraws every automatic approval not
|
|
9
|
+
yet merged. When it's on, a release task first reads "Checking
|
|
10
|
+
automatically" (`auto-check`), which lets its release check run and
|
|
11
|
+
nothing else. Once GitHub CI and the release check both pass on the exact
|
|
12
|
+
candidate, it approves itself (`auto-green`) and the release continues as
|
|
13
|
+
after your yes. You're told, not asked.
|
|
14
|
+
- **What an automatic approval must prove.** Up to two failed attempts per
|
|
15
|
+
CI job, and two failed release-check builds on the same commit, are
|
|
16
|
+
tolerated as flakes. Past that it asks you. The change must touch only
|
|
17
|
+
allowed paths: product code and its tests, test files under `test/`,
|
|
18
|
+
docs, the changelog and a version-only `package.json` bump. Scripts, CI,
|
|
19
|
+
configuration, dependencies, policy files and test helpers (found from the
|
|
20
|
+
tests' own imports) need a person. CI is read again before the deploy, the
|
|
21
|
+
CLI switch, the merge and the tag. Anything not green stops the release,
|
|
22
|
+
withdraws the approval and tells you.
|
|
23
|
+
- **A red CI after the merge holds publishing.** No tag, npm publish or
|
|
24
|
+
Homebrew update happens until a person runs
|
|
25
|
+
`toolroll release publish-ok <task>` with their password.
|
|
26
|
+
- **Stricter CI verdicts on every release, yours too.** Only checks on the
|
|
27
|
+
exact head count. Required checks must come from GitHub Actions, so
|
|
28
|
+
another app's same-name success can't hide a failure. A newer run of the
|
|
29
|
+
same workflow supersedes its older runs on the commit.
|
|
30
|
+
- **Toolroll's own git on agent checkouts runs against a frozen view.**
|
|
31
|
+
Every git command the plane runs on a checkout an agent wrote (staging,
|
|
32
|
+
committing, cleanup, pool refresh, scouts, planners, coding sessions, page
|
|
33
|
+
reads) runs against a private copy of the repository's settings taken
|
|
34
|
+
once. Changes the agent makes to them afterwards are never read.
|
|
35
|
+
- **Git settings are denied by default.** Only audited config sections and
|
|
36
|
+
variables pass. Aliases, URL rewrites that reach remote helpers, empty
|
|
37
|
+
filter names and `hook.*` are refused. Writes allow no network transport.
|
|
38
|
+
Local git-lfs installs still work.
|
|
39
|
+
- **Writes are bound to the project's recorded repository.** A checkout
|
|
40
|
+
whose `.git` points anywhere else is refused. Before git writes through a
|
|
41
|
+
view, every path it could write is walked for links and hard links. Plane
|
|
42
|
+
git writes on one checkout run one at a time. On git before 2.48, a
|
|
43
|
+
checkout added through the view is pointed at its repository, not at the
|
|
44
|
+
view.
|
|
45
|
+
- **A git incident is recorded and you're notified.** A swap seen after git
|
|
46
|
+
ran is logged and raised as an attention notification. The notification
|
|
47
|
+
names the project by Toolroll's own record, never by a path the agent
|
|
48
|
+
chose.
|
|
49
|
+
- **`discover --dirty` reads repositories through the same hardened git.**
|
|
50
|
+
- Database: schema v136 (unchanged).
|
|
51
|
+
|
|
3
52
|
## 0.9.75 — 2026-10-27
|
|
4
53
|
|
|
5
54
|
- **Opening a page only shows it.** Pages no longer run checks, tidy up or
|
package/dist/agent-fence.js
CHANGED
|
@@ -32,6 +32,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
32
32
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
33
33
|
import { readMonitoring } from "./monitoring-settings.js";
|
|
34
34
|
import { existingFolders } from "./names.js";
|
|
35
|
+
import { ensureGitViewsRoot, gitViewsRoot } from "./git-views.js";
|
|
35
36
|
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
36
37
|
import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
|
|
37
38
|
import { protectedStorageDenies, protectedStorageNames } from "./protected-storage.js";
|
|
@@ -175,6 +176,13 @@ export function agentFence(options) {
|
|
|
175
176
|
fence.add(join(state, entry));
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
// The plane's private views of repositories for its own git (git-views.ts): made first, so no build is the one to
|
|
180
|
+
// create them, and denied by name whether or not that worked (a sandbox that can't cover the path refuses to launch).
|
|
181
|
+
try {
|
|
182
|
+
ensureGitViewsRoot(home);
|
|
183
|
+
}
|
|
184
|
+
catch { /* fenced by name below either way */ }
|
|
185
|
+
fence.add(gitViewsRoot(home));
|
|
178
186
|
for (const secrets of existingFolders(home, { dot: true }))
|
|
179
187
|
fence.add(secrets);
|
|
180
188
|
// v104: the folder the audit stream writes to holds the whole instance's history.
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* An allowlist, never a deny-list: a person's yes (`password`, `chat`) stands unless a machine made that seal; a mode
|
|
6
6
|
* or a flow grant stands while its signature does; a standing order's firing (`schedule`, or an old firing stored as
|
|
7
|
-
* `password`) stands while its approver can still approve in the project
|
|
7
|
+
* `password`) stands while its approver can still approve in the project; an automatic release seal (`auto-check`,
|
|
8
|
+
* `auto-green`: docs/plans/auto-approve-green.md) stands on its own evidence row while the project's setting is on and
|
|
9
|
+
* the person who turned it on can still approve there. Everything else — the lead (`lead`, which
|
|
8
10
|
* phase 1 never lets stand), a null basis, a word this code doesn't write — stands on nothing.
|
|
9
11
|
*
|
|
10
12
|
* The seal's origin is read from the acts recorded AT the seal's own instant, exactly as scope.ts sealOriginOf reads
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
*/
|
|
13
15
|
import { grantStandsSql } from "./flow-unattended.js";
|
|
14
16
|
import { accountReachesSql } from "./project-access.js";
|
|
17
|
+
import { autoSealSql } from "./release-authority.js";
|
|
15
18
|
/** The proofs a person gives (src/person-proof.ts), as an SQL list. */
|
|
16
19
|
const PERSON_PROOFS = "('password-now', 'sso-now', 'fresh-password', 'fresh-sso', 'session', 'cli-login', 'chat', 'passkey')";
|
|
17
20
|
/** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
|
|
@@ -25,7 +28,8 @@ export const authorityStandsSql = (scope, repo, now) => `COALESCE((((COALESCE(${
|
|
|
25
28
|
WHERE om.repo = ${repo} AND om.revoked_at IS NULL AND om.absolute_expiry > ${now} AND om.digest = ${scope}.mode_digest
|
|
26
29
|
AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", repo)}))
|
|
27
30
|
OR (${scope}.approval_basis = 'grant' AND ${grantStandsSql(`${scope}.mode_digest`, now, repo)})
|
|
28
|
-
OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)})
|
|
31
|
+
OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)})
|
|
32
|
+
OR (${scope}.approval_basis IN ('auto-check', 'auto-green') AND ${autoSealSql(scope, repo)} AND ${approverStandsSql(scope, repo)}))
|
|
29
33
|
AND ${releaseStandsSql(scope, now)}), 0)`;
|
|
30
34
|
/** SQL: a `carried` seal stands only on its own carry row — this task, this approved digest, this candidate, and the
|
|
31
35
|
* reference `<authority>:<carry>` the carry transaction wrote (release-once.md). */
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { type Html } from "./html.js";
|
|
8
8
|
import type { ApprovalRules } from "./approval-policy.js";
|
|
9
|
+
/** The rules' style, and the same for the Releases section under its own class (so a page holds one `.approval-rules`
|
|
10
|
+
* form, not two). */
|
|
9
11
|
export declare const APPROVAL_RULES_CSS: string;
|
|
10
12
|
export type ApprovalRulesView = {
|
|
11
13
|
repo: string;
|
|
@@ -20,6 +22,15 @@ export type ApprovalRulesView = {
|
|
|
20
22
|
};
|
|
21
23
|
/** The rules in plain words, for anyone who can see the project. */
|
|
22
24
|
export declare function rulesSummary(rules: ApprovalRules): string;
|
|
25
|
+
/** Settings → Approval rules → Releases (docs/plans/auto-approve-green.md): who approves this project's releases. */
|
|
26
|
+
export type ReleaseApprovalView = {
|
|
27
|
+
repo: string;
|
|
28
|
+
auto: boolean;
|
|
29
|
+
setBy: string | null;
|
|
30
|
+
at: string | null;
|
|
31
|
+
canChange: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare function releaseApprovalHtml(view: ReleaseApprovalView): Html;
|
|
23
34
|
export declare function approvalRulesHtml(view: ApprovalRulesView, notice: {
|
|
24
35
|
said?: string | null;
|
|
25
36
|
problem?: string | null;
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* ledger, before → after.
|
|
6
6
|
*/
|
|
7
7
|
import { html, postForm, reauth } from "./html.js";
|
|
8
|
-
|
|
8
|
+
const RULES_CSS = `.approval-rules{max-width:720px;min-width:0}.approval-rules fieldset{border:0;padding:0;margin:18px 0 0;min-width:0}` +
|
|
9
9
|
`.approval-rules legend{font-weight:600;font-size:.875rem;margin-bottom:8px}.approval-rules .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:start;margin:0 0 10px;font-weight:400}` +
|
|
10
10
|
`.approval-rules .choice input{margin:.2rem 0 0}.approval-rules .choice strong{display:block;font-size:.875rem;font-weight:500}.approval-rules .choice small{display:block;color:var(--muted-foreground);font-size:.8125rem;margin-top:2px}` +
|
|
11
11
|
`.approval-rules textarea{min-height:88px;font-family:var(--font-mono);font-size:.8125rem}.approval-rules .paths{margin:4px 0 0 26px}` +
|
|
12
12
|
`.approval-rules .current{margin:0 0 4px}.approval-rules .step-up{margin-top:16px}.approval-rules button[type=submit]{margin-top:14px}`;
|
|
13
|
+
/** The rules' style, and the same for the Releases section under its own class (so a page holds one `.approval-rules`
|
|
14
|
+
* form, not two). */
|
|
15
|
+
export const APPROVAL_RULES_CSS = RULES_CSS + RULES_CSS.replaceAll(".approval-rules", ".release-approval");
|
|
13
16
|
/** The rules in plain words, for anyone who can see the project. */
|
|
14
17
|
export function rulesSummary(rules) {
|
|
15
18
|
const parts = [];
|
|
@@ -21,6 +24,15 @@ export function rulesSummary(rules) {
|
|
|
21
24
|
parts.push(`Work touching ${rules.protectedPaths.join(", ")} is protected: two people approve it, never a subagent or an operating mode.`);
|
|
22
25
|
return parts.length === 0 ? "No approval rules: anyone who can approve on this project can approve its work, including their own." : parts.join(" ");
|
|
23
26
|
}
|
|
27
|
+
export function releaseApprovalHtml(view) {
|
|
28
|
+
const since = view.setBy === null ? "" : html ` <span class="meta">Changed by ${view.setBy} on ${(view.at ?? "").slice(0, 10)}.</span>`;
|
|
29
|
+
const current = html `<p class="current">${view.auto ? "Releases approve themselves once the pull request's CI and the release check pass on the exact commit. Anything red still asks you." : "Every release waits for your approval."}${since}</p>`;
|
|
30
|
+
const heading = html `<h2>Release approval</h2>`;
|
|
31
|
+
if (!view.canChange)
|
|
32
|
+
return html `<section class="release-approval">${heading}${current}<p class="meta">An approver of this project sets this.</p></section>`;
|
|
33
|
+
const choice = (value, title, hint) => html `<label class="choice"><input type="radio" name="action" value="${value}"${(view.auto ? "auto" : "ask") === value ? html ` checked` : ""}><span><strong>${title}</strong><small>${hint}</small></span></label>`;
|
|
34
|
+
return html `<section class="release-approval">${heading}${current}${postForm("/settings/release-approval", html `<fieldset aria-label="Release approval">${choice("ask", "Ask me", "A release deploys only after you approve it.")}${choice("auto", "Approve automatically when checks pass", "You're told when a release approves itself. A failed check, red CI or a CI failure after the deploy still stops and asks you.")}</fieldset><div class="step-up"><label>Your Toolroll password (to approve automatically)<input type="password" name="password" autocomplete="current-password"></label></div><button type="submit">Save</button>`, { hidden: { repo: view.repo } })}</section>`;
|
|
35
|
+
}
|
|
24
36
|
export function approvalRulesHtml(view, notice) {
|
|
25
37
|
const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
|
|
26
38
|
const changed = view.rules.updatedBy === null ? "" : html ` <span class="meta">Changed by ${view.rules.updatedBy} on ${(view.rules.updatedAt ?? "").slice(0, 10)}.</span>`;
|
package/dist/batch-checks.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { run as execRun } from "./exec.js";
|
|
6
|
+
import { hardenedGit } from "./teardown-git.js";
|
|
6
7
|
import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
|
|
7
8
|
import { runWithIsolatedDatabase } from "./child-database.js";
|
|
8
9
|
import { boundStreamHeadTail, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
@@ -86,7 +87,8 @@ function finishOne(store, one, repo, task, status, detail, at) {
|
|
|
86
87
|
async function checkCohort(store, root, repo, cohort, deps) {
|
|
87
88
|
const now = deps.now ?? (() => new Date());
|
|
88
89
|
const runner = deps.runner ?? execRun;
|
|
89
|
-
|
|
90
|
+
// The project's repository and checkouts of builds' commits, both agent-writable: the hardened git (teardown-git.ts).
|
|
91
|
+
const git = deps.git ?? hardenedGit(execRun);
|
|
90
92
|
const command = store.liveVerifyCommand(repo);
|
|
91
93
|
if (command === null)
|
|
92
94
|
return 0;
|
package/dist/builder.js
CHANGED
|
@@ -255,7 +255,7 @@ const BRANCH_QUERIES = [
|
|
|
255
255
|
function buildCheckoutGit(given, worktree, mainCheckout = null) {
|
|
256
256
|
if (given !== run)
|
|
257
257
|
return given;
|
|
258
|
-
const hardened = checkoutGit(given);
|
|
258
|
+
const hardened = checkoutGit(given, "build", { repo: mainCheckout });
|
|
259
259
|
const real = (path) => { try {
|
|
260
260
|
return realpathSync(resolve(path));
|
|
261
261
|
}
|
package/dist/coding-workspace.js
CHANGED
|
@@ -400,8 +400,10 @@ export class CodingWorkspace {
|
|
|
400
400
|
}, 50);
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
-
async git(repo, args) {
|
|
404
|
-
|
|
403
|
+
async git(repo, args, project = repo) {
|
|
404
|
+
// The project's repository and the session's checkout are agent-written: the hardened git, against a frozen view of
|
|
405
|
+
// the repository's config and info (teardown-git.ts checkoutGit).
|
|
406
|
+
const result = await checkoutGit(run, 'build', { repo: project })('git', ['--no-optional-locks', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
|
|
405
407
|
if (result.code !== 0)
|
|
406
408
|
throw Error('Git could not complete this workspace operation. Check the repository and try again.');
|
|
407
409
|
return result.stdout;
|
|
@@ -520,13 +522,13 @@ export class CodingWorkspace {
|
|
|
520
522
|
}
|
|
521
523
|
async verifyWorktree(session) {
|
|
522
524
|
const [branch, common, original] = await Promise.all([
|
|
523
|
-
this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD']),
|
|
524
|
-
this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
|
|
525
|
+
this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD'], session.repo),
|
|
526
|
+
this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir'], session.repo),
|
|
525
527
|
this.git(session.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
|
|
526
528
|
]);
|
|
527
529
|
if (branch.trim() !== session.branch || realpathSync(common.trim()) !== realpathSync(original.trim()))
|
|
528
530
|
throw Error('This checkout no longer matches the saved project and branch. Restore that checkout before resuming.');
|
|
529
|
-
await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD']);
|
|
531
|
+
await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD'], session.repo);
|
|
530
532
|
}
|
|
531
533
|
priorProcessesGone() {
|
|
532
534
|
const row = this.db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get();
|
|
@@ -72,6 +72,7 @@ export declare const CONSOLE_FORMS: {
|
|
|
72
72
|
readonly code: FormContract<"acceptance" | "answers" | "base" | "candidate" | "decision" | "goal" | "model" | "password" | "prompt" | "repo" | "requestId" | "title" | "visual" | `question:${string}`>;
|
|
73
73
|
readonly sweep: FormContract<"confirm" | "snapshot">;
|
|
74
74
|
readonly passwordless: FormContract<"action" | "password">;
|
|
75
|
+
readonly releaseApproval: FormContract<"action" | "password" | "repo">;
|
|
75
76
|
readonly term: FormContract<"password" | "program" | "repo">;
|
|
76
77
|
readonly skillsRevise: FormContract<"feedback" | "nonce" | "repo" | "run">;
|
|
77
78
|
readonly skillsChange: FormContract<"action" | "content" | "files" | "identity" | "method" | "nonce" | "repo" | "restore" | "revision" | "sample" | "sha" | "url">;
|
|
@@ -81,6 +81,7 @@ export const CONSOLE_FORMS = {
|
|
|
81
81
|
code: formContract("POST /code, /code/start, /code/<session>/<act>, /code/<anything else>", ["repo", "password", "title", "model", "prompt", "requestId", "base", "candidate", "goal", "acceptance", "visual", "decision", "answers"], { prefixes: ["question:"] }),
|
|
82
82
|
sweep: formContract("POST /sweeps/<sweep>/cancel-rest", ["confirm", "snapshot"]),
|
|
83
83
|
passwordless: formContract("POST /settings/passwordless", ["action", "password"]),
|
|
84
|
+
releaseApproval: formContract("POST /settings/release-approval", ["repo", "action", "password"]),
|
|
84
85
|
term: formContract("POST /term/start, /term/<terminal>/close, /term/<terminal>/remove", ["repo", "program", "password"]),
|
|
85
86
|
skillsRevise: formContract("POST /settings/skills/revise", ["run", "repo", "feedback", "nonce"]),
|
|
86
87
|
skillsChange: formContract("POST /settings/skills/import, /settings/skills/change", ["repo", "identity", "revision", "method", "content", "files", "url", "action", "sha", "sample", "nonce", "restore"]),
|
package/dist/deploy-gate.d.ts
CHANGED
|
@@ -14,4 +14,7 @@ export declare function deploymentGate(store: Store, input: {
|
|
|
14
14
|
runId: number;
|
|
15
15
|
evidenceRoot: string;
|
|
16
16
|
now: Date;
|
|
17
|
+
/** Inside `toolroll release` (its own reserved deploy): a release's own automatic approval also stands
|
|
18
|
+
* (docs/plans/auto-approve-green.md). Anywhere else only a person's approval deploys. */
|
|
19
|
+
releaseFlow?: boolean;
|
|
17
20
|
}): DeploymentGate;
|
package/dist/deploy-gate.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* same checks for the deployment script, proven again there with the installed runtime's modules.)
|
|
7
7
|
*/
|
|
8
8
|
import { assignmentOf } from "./assignment.js";
|
|
9
|
-
import { liveReleaseOf, personApprovalOf } from "./scope.js";
|
|
9
|
+
import { liveReleaseOf, personApprovalOf, releaseApprovalOf } from "./scope.js";
|
|
10
10
|
import { verificationEvidence } from "./verification-evidence.js";
|
|
11
11
|
/** Whether run `runId` is a deployable candidate now, read only from the plane's records and this runtime's code. */
|
|
12
12
|
export function deploymentGate(store, input) {
|
|
@@ -33,7 +33,8 @@ export function deploymentGate(store, input) {
|
|
|
33
33
|
const release = liveReleaseOf(store, ref.externalId, "deploy", input.now);
|
|
34
34
|
if (release.applies && !release.ok)
|
|
35
35
|
return refuse(`This release can't deploy: ${release.why}.`);
|
|
36
|
-
const anchorPerson = release.applies && release.ok
|
|
36
|
+
const anchorPerson = release.applies && release.ok
|
|
37
|
+
? (input.releaseFlow === true ? releaseApprovalOf(store, release.authority.anchorTask, input.now) : personApprovalOf(store, release.authority.anchorTask)) : null;
|
|
37
38
|
const person = anchorPerson ?? personApprovalOf(store, ref.externalId);
|
|
38
39
|
if (!person.ok)
|
|
39
40
|
return refuse(`A person has to approve this candidate's scope: ${person.why}.`);
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -97,7 +97,6 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
|
|
|
97
97
|
export declare function lockHeld(file: string): boolean;
|
|
98
98
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
99
99
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
100
|
-
version?: never;
|
|
101
100
|
active: boolean;
|
|
102
101
|
phase: string;
|
|
103
102
|
detail: string;
|
|
@@ -111,6 +110,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
111
110
|
wasRunning?: never;
|
|
112
111
|
backupPath?: never;
|
|
113
112
|
updateId?: never;
|
|
113
|
+
version?: never;
|
|
114
114
|
buildId?: never;
|
|
115
115
|
workDir?: never;
|
|
116
116
|
} | {
|
package/dist/discover.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { basename } from "node:path";
|
|
21
21
|
import { run } from "./exec.js";
|
|
22
|
+
import { checkoutGit } from "./teardown-git.js";
|
|
22
23
|
import { parseWorktrees, parseBranches } from "./git.js";
|
|
23
24
|
import { findRepos } from "./scan.js";
|
|
24
25
|
/** Enough to keep git busy without turning a laptop fan on. */
|
|
@@ -43,7 +44,11 @@ const BRANCH_FORMAT = "--format=%(refname:short)%09%(upstream:track)%09%(committ
|
|
|
43
44
|
const BRANCH_FORMAT_CHEAP = "--format=%(refname:short)%09%09%(committerdate:iso8601-strict)";
|
|
44
45
|
export async function inspectRepo(path, options = {}) {
|
|
45
46
|
const { runner = run, dirty = false } = options;
|
|
46
|
-
|
|
47
|
+
// Scanned and enrolled projects share their repository with every build, which can write its config, info and hooks
|
|
48
|
+
// (`--dirty`'s status would run a planted fsmonitor or filter): the hardened git, against a frozen view of the
|
|
49
|
+
// repository (teardown-git.ts checkoutGit). An injected runner (tests) is used as given.
|
|
50
|
+
const hardened = runner === run ? checkoutGit(run, "build") : runner;
|
|
51
|
+
const git = (args, timeoutMs = REF_TIMEOUT_MS) => hardened(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs });
|
|
47
52
|
const [worktrees, branchRead, head, remote, status] = await Promise.all([
|
|
48
53
|
git(["worktree", "list", "--porcelain"]),
|
|
49
54
|
readBranches(git),
|
package/dist/first-run.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* the composer: nothing is filed until the person sends it and confirms.
|
|
7
7
|
*/
|
|
8
8
|
import { run } from "./exec.js";
|
|
9
|
+
import { hardenedGit } from "./teardown-git.js";
|
|
9
10
|
import { githubIssues } from "./issues.js";
|
|
10
11
|
export const SIGN_IN_COMMAND = "claude auth login";
|
|
11
12
|
export const SANDBOX_COMMAND = "npx toolroll demo";
|
|
@@ -95,7 +96,8 @@ export async function findFirstTasks(repo, runner = run) {
|
|
|
95
96
|
let todos = [];
|
|
96
97
|
if (issues.length < 3) {
|
|
97
98
|
try {
|
|
98
|
-
|
|
99
|
+
// The project's repository is shared with builds that can write its config: the hardened read (teardown-git.ts).
|
|
100
|
+
const result = await (runner === run ? hardenedGit(run, "read") : runner)("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
|
|
99
101
|
if (result.code === 0 || result.stdout !== "")
|
|
100
102
|
todos = parseTodoLines(result.stdout);
|
|
101
103
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type GitIncident = {
|
|
2
|
+
at: string;
|
|
3
|
+
checkout: string;
|
|
4
|
+
repository: string;
|
|
5
|
+
/** Folders of the repository found swapped, and where each now leads (null: its pointers changed instead). */
|
|
6
|
+
swapped: {
|
|
7
|
+
path: string;
|
|
8
|
+
leadsTo: string | null;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
type Listener = (incident: GitIncident) => void;
|
|
12
|
+
/** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
|
|
13
|
+
export declare function onGitIncident(listener: Listener): () => void;
|
|
14
|
+
/** Record and tell one incident. */
|
|
15
|
+
export declare function gitIncident(input: {
|
|
16
|
+
checkout: string;
|
|
17
|
+
repository: string;
|
|
18
|
+
swapped: GitIncident["swapped"];
|
|
19
|
+
record: string;
|
|
20
|
+
}): GitIncident;
|
|
21
|
+
/** What noticeGitIncidents needs of the store: its durable outbox, and its own project records. */
|
|
22
|
+
export type IncidentOutbox = {
|
|
23
|
+
enqueueNotification(notification: {
|
|
24
|
+
dedupeKey: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
subject: string;
|
|
27
|
+
body: string;
|
|
28
|
+
pushClass?: "attention";
|
|
29
|
+
source?: {
|
|
30
|
+
project: string;
|
|
31
|
+
} | {
|
|
32
|
+
installation: true;
|
|
33
|
+
};
|
|
34
|
+
}, now: Date): boolean;
|
|
35
|
+
listProjects(): {
|
|
36
|
+
path: string;
|
|
37
|
+
name: string;
|
|
38
|
+
}[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
|
|
42
|
+
* register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
|
|
43
|
+
* while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
|
|
44
|
+
* newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
|
|
45
|
+
* else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
|
|
46
|
+
* unsubscribe.
|
|
47
|
+
*/
|
|
48
|
+
export declare function noticeGitIncidents(outbox: IncidentOutbox, now?: () => Date, record?: string): () => void;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A git incident: the plane's own git wrote through a view of a repository (teardown-git.ts gitView) while a folder of
|
|
3
|
+
* that repository the view links to was swapped for a link elsewhere, or its git pointers changed (the residual the
|
|
4
|
+
* view can't prevent: git takes paths, and a build that outlived its turn can still rename a folder of the shared
|
|
5
|
+
* `.git` mid-command). Seen after git ran, it is:
|
|
6
|
+
*
|
|
7
|
+
* - recorded, one JSON line in `~/.toolroll/git-incidents.jsonl` (beside the fenced views folder, git-views.ts);
|
|
8
|
+
* - told to every listener (the console's, when it registers one; otherwise the plane's stderr);
|
|
9
|
+
* - and the command is reported refused, so no caller takes its result as done.
|
|
10
|
+
*
|
|
11
|
+
* Nothing is rolled back automatically: where a swapped folder leads is the agent's choice, and the plane never runs
|
|
12
|
+
* git there on its say (a confused deputy); what git wrote is for a person, named in the record.
|
|
13
|
+
*/
|
|
14
|
+
import { appendFileSync, realpathSync } from "node:fs";
|
|
15
|
+
import { basename, dirname } from "node:path";
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
/** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
|
|
18
|
+
export function onGitIncident(listener) {
|
|
19
|
+
listeners.add(listener);
|
|
20
|
+
return () => listeners.delete(listener);
|
|
21
|
+
}
|
|
22
|
+
/** Record and tell one incident. */
|
|
23
|
+
export function gitIncident(input) {
|
|
24
|
+
const incident = { at: new Date().toISOString(), checkout: input.checkout, repository: input.repository, swapped: input.swapped };
|
|
25
|
+
try {
|
|
26
|
+
appendFileSync(input.record, `${JSON.stringify(incident)}\n`, { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
catch { /* still told */ }
|
|
29
|
+
if (listeners.size === 0)
|
|
30
|
+
process.stderr.write(`Toolroll git incident: ${JSON.stringify(incident)}\n`);
|
|
31
|
+
for (const listener of listeners) {
|
|
32
|
+
try {
|
|
33
|
+
listener(incident);
|
|
34
|
+
}
|
|
35
|
+
catch { /* every listener hears it */ }
|
|
36
|
+
}
|
|
37
|
+
return incident;
|
|
38
|
+
}
|
|
39
|
+
const noticed = new WeakSet();
|
|
40
|
+
/**
|
|
41
|
+
* Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
|
|
42
|
+
* register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
|
|
43
|
+
* while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
|
|
44
|
+
* newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
|
|
45
|
+
* else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
|
|
46
|
+
* unsubscribe.
|
|
47
|
+
*/
|
|
48
|
+
export function noticeGitIncidents(outbox, now = () => new Date(), record = "~/.toolroll/git-incidents.jsonl") {
|
|
49
|
+
if (noticed.has(outbox))
|
|
50
|
+
return () => { };
|
|
51
|
+
noticed.add(outbox);
|
|
52
|
+
const stop = onGitIncident(incident => {
|
|
53
|
+
const main = basename(incident.repository) === ".git" ? dirname(incident.repository) : incident.repository;
|
|
54
|
+
const real = (path) => { try {
|
|
55
|
+
return realpathSync(path);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
} };
|
|
60
|
+
const known = outbox.listProjects().find(project => real(project.path) !== null && real(project.path) === real(main)) ?? null;
|
|
61
|
+
outbox.enqueueNotification({
|
|
62
|
+
dedupeKey: `git-incident:${incident.at}:${known?.path ?? "unrecorded"}`, kind: "git-incident", pushClass: "attention",
|
|
63
|
+
source: known === null ? { installation: true } : { project: known.path },
|
|
64
|
+
subject: `Git incident in ${known === null ? "a project" : known.name}: a folder of its repository changed while Toolroll's git ran`,
|
|
65
|
+
body: `Toolroll stopped trusting that git command and changed nothing more. Nothing was rolled back: check the repository by hand. The checkout and the folders that changed are in ${record}.`,
|
|
66
|
+
}, now());
|
|
67
|
+
});
|
|
68
|
+
return () => { stop(); noticed.delete(outbox); };
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The views folder under `home`. */
|
|
2
|
+
export declare function gitViewsRoot(home: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
|
|
5
|
+
* the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureGitViewsRoot(home: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
|
|
10
|
+
* HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
|
|
11
|
+
* is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
|
|
12
|
+
* process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
|
|
13
|
+
*/
|
|
14
|
+
export declare function lockCheckout(home: string, checkout: string, timeoutMs?: number): Promise<() => void>;
|
|
15
|
+
/**
|
|
16
|
+
* Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
|
|
17
|
+
* another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
|
|
18
|
+
* under the lock its caller already holds instead of waiting on itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function withCheckoutLock<T>(home: string, checkout: string, work: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the plane makes its private views of a repository for git (teardown-git.ts gitView): a folder of its own under
|
|
3
|
+
* the home folder's `~/.toolroll`, which every fence denies to builds, read and write (agent-fence.ts agentFence names
|
|
4
|
+
* this folder by name, made first so no build can be the one to create it). A view in the system temp folder could be
|
|
5
|
+
* rewritten by a build that writes anywhere and outlived its turn (Codex review of e48e9d8d).
|
|
6
|
+
*/
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { closeSync, constants as fsConstants, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
/** The views folder under `home`. */
|
|
12
|
+
export function gitViewsRoot(home) {
|
|
13
|
+
return join(home, ".toolroll", "git-views");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
|
|
17
|
+
* the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export function ensureGitViewsRoot(home) {
|
|
20
|
+
const uid = process.getuid?.();
|
|
21
|
+
const base = join(home, ".toolroll");
|
|
22
|
+
const root = gitViewsRoot(home);
|
|
23
|
+
for (const dir of [base, root]) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== "EEXIST")
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const held = lstatSync(dir);
|
|
32
|
+
if (!held.isDirectory() || held.isSymbolicLink() || (uid !== undefined && held.uid !== uid))
|
|
33
|
+
throw new Error(`${dir} isn't a folder of this user's own`);
|
|
34
|
+
}
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
|
|
39
|
+
* HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
|
|
40
|
+
* is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
|
|
41
|
+
* process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
|
|
42
|
+
*/
|
|
43
|
+
export async function lockCheckout(home, checkout, timeoutMs = 10 * 60_000) {
|
|
44
|
+
const folder = join(ensureGitViewsRoot(home), "locks");
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(folder, { mode: 0o700 });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== "EEXIST")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const held = lstatSync(folder);
|
|
53
|
+
if (!held.isDirectory() || held.isSymbolicLink())
|
|
54
|
+
throw new Error(`${folder} isn't a folder of this user's own`);
|
|
55
|
+
const path = join(folder, `${createHash("sha256").update(checkout).digest("hex")}.lock`);
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
for (;;) {
|
|
58
|
+
try {
|
|
59
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
60
|
+
try {
|
|
61
|
+
writeSync(fd, `${process.pid}\n`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
let released = false;
|
|
67
|
+
return () => { if (!released) {
|
|
68
|
+
released = true;
|
|
69
|
+
rmSync(path, { force: true });
|
|
70
|
+
} };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== "EEXIST")
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
// Held: by a live process, wait; by one that is gone, take it over.
|
|
77
|
+
let owner = NaN;
|
|
78
|
+
try {
|
|
79
|
+
owner = Number(readFileSync(path, "utf8").trim());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
owner = NaN;
|
|
83
|
+
}
|
|
84
|
+
if (Number.isSafeInteger(owner) && owner > 0 && owner !== process.pid && !alive(owner)) {
|
|
85
|
+
rmSync(path, { force: true });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() > deadline)
|
|
89
|
+
throw new Error("another of Toolroll's git commands on this checkout is still running");
|
|
90
|
+
await new Promise(done => setTimeout(done, 25));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function alive(pid) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return error.code === "EPERM";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The checkouts the current async call chain already holds (lockCheckout through withCheckoutLock). */
|
|
103
|
+
const heldHere = new AsyncLocalStorage();
|
|
104
|
+
/**
|
|
105
|
+
* Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
|
|
106
|
+
* another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
|
|
107
|
+
* under the lock its caller already holds instead of waiting on itself.
|
|
108
|
+
*/
|
|
109
|
+
export async function withCheckoutLock(home, checkout, work) {
|
|
110
|
+
const held = heldHere.getStore() ?? new Set();
|
|
111
|
+
if (held.has(checkout))
|
|
112
|
+
return work();
|
|
113
|
+
const release = await lockCheckout(home, checkout);
|
|
114
|
+
try {
|
|
115
|
+
return await heldHere.run(new Set([...held, checkout]), work);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
release();
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/grant.js
CHANGED
|
@@ -178,7 +178,9 @@ async function observedByGit(repo, paths, runner) {
|
|
|
178
178
|
for (const path of local) {
|
|
179
179
|
const asked = [path, `${normalise(path)}/`];
|
|
180
180
|
const ignored = await Promise.all(asked.map(async (candidate) => {
|
|
181
|
-
|
|
181
|
+
// check-ignore reads the index, so a planted fsmonitor would run: off (and hooks, lazy fetch) on the command line,
|
|
182
|
+
// which wins over any config the repository's builds can write.
|
|
183
|
+
const result = await runner("git", ["--no-optional-locks", "--no-lazy-fetch", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "check-ignore", "-q", candidate], {
|
|
182
184
|
cwd: repo,
|
|
183
185
|
});
|
|
184
186
|
return result.code === 0;
|