Public: anyone can read this; the crew can change it.

Trusted sources fold in by themselves, in real time; everyone else waits for review #8984

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8937-trusted-sources-fold-in-by-themselves-in → main 9 commits fork 3 threads · 3 unresolved

  • Merged. mason/8937-trusted-sources-fold-in-by-themselves-in was merged into main as 8b55eaf.

Review

Sign in to review this change.
28 files changed +1594 −17
docs/design/audit.md +1
@@ -100,6 +100,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
100100 - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen.
101101 - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name.
102102 - `/settings/telemetry` — `pages/telemetry.ts` · which agents may read telemetry · `/settings/crew` (agent grants) · none seen.
103 - `/settings/trusted` — `pages/trusted.ts` · who may fold in without review, until when, and the kill switch · `/review`, `/settings/crew` · none seen.
103104 - `/settings/spend` — `pages/spend-tab.ts` · what the crew's runs spent, by credential, app, agent, issue and run kind · `/settings/subscriptions`, `/board/returns` · none seen. First tab of Usage (#2692).
104105 - `/settings/resilience` — `pages/domains.ts` · failure domains and their last drill · `/resilience` · none seen.
105106 - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen.
docs/features/trusted-sources.md added +14
@@ -0,0 +1,14 @@
1 # Trusted sources (#8937)
2
3 Settings → Trusted sources lists who folds in without a person's review: an account **on a named forge**, until a date, set by whom. The same name on another forge is a stranger. Remove is one click; past its date an entry falls to review by itself, with a `trust.expiring` note 3 days before. Every change is an event (`trust.set`, `trust.removed`). The list starts empty: an owner fills it (nothing is seeded by a migration).
4
5 A trusted source's pull (recorded in `pull_source`, from a followed upstream's address) is admitted by the autonomy tick like the crew's own: its check, the boot proof and the walls still stand, then it lands by true merge with `Landed-from` / `Landed-via` trailers. The walls (`src/crew/fold_walls.ts`) are an allowlist: a trusted pull may change `src/`, `test/`, `docs/`, `examples/` and the README; any other path waits for a person, and so do secrets, credentials, permissions, the contract, release machinery, every migration and the machinery that enforces these walls (fold-in, autonomy, the trust list's actions, page and screen, the auth routes, event audiences). Everyone else is a guest: nothing runs with secrets and the pull waits on `/review` with Approve and Decline.
6
7 Work whose `Landed-via` already names this forge is not taken again, so forges following each other in a ring do not re-land it. The kill switch ("Stop all folding in") ends all of it at once; "Revert landings" opens a revert pull for everything one source landed.
8
9
10 **Where it comes from.** A pull is stamped with its source only when the upstream follow opens it, from the followed address (`https://forge/account/repo`), once; a person's or a guest's pull under the same branch name is never stamped. The account is the owner of the followed repository, **not a verified commit author**: trust is "work from the repository this account owns", and commits keep whatever author they carry. There is no other intake: the autonomy tick's sweep is how it lands.
11
12 **Visible.** A landing carries `Landed-from` / `Landed-via` trailers, the `pull.merged` event names `source`, and the changelog (which swaps live when a pull merges) credits the row "folded in from account@forge".
13
14 **Both doors.** Settings → Trusted sources on the web, and **Trusted sources** in the terminal's command palette (a forge admin's): `a` trusts, `x` removes, `s` is the kill switch (stop or resume), `v` reverts what the one under the cursor landed. Both ask before reverting, and both go through `src/actions/trusted_sources.ts`.
src/actions/pulls.ts +10 −4
@@ -81,6 +81,7 @@ import {
8181 import { canWrite } from "../core/repos.ts";
8282 import { refreshPullHead as refreshHead } from "../core/following.ts";
8383 import { carryBranchThrough } from "../origins/push-through.ts";
84 import { ownHost, pullSourceOf, sourceTrailers } from "../core/trusted_sources.ts";
8485
8586 export interface OpenPullInput {
8687 title: string;
@@ -402,10 +403,14 @@ export async function mergePull(
402403 const asked = toldBy(app.db, pull);
403404 const requester =
404405 asked === null ? undefined : getUserByName(app.db, asked);
405 const trailers = landingTrailers(
406 requester ? commitIdentity(app.config, requester) : null,
407 lander,
408 );
406 const from = pullSourceOf(app.db, pull.id);
407 const trailers = [
408 landingTrailers(
409 requester ? commitIdentity(app.config, requester) : null,
410 lander,
411 ),
412 ...(from === undefined ? [] : [sourceTrailers(from, ownHost(app.config))]),
413 ].join("\n");
409414 const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name);
410415 const at = await pullDir(app.db, app.reposRoot, repo, pull);
411416 const carriedBefore =
@@ -465,6 +470,7 @@ export async function mergePull(
465470 number: pull.number,
466471 strategy,
467472 mergeOid: merged.value.mergeOid,
473 ...(from === undefined ? {} : { source: `${from.account}@${from.host}` }),
468474 },
469475 });
470476 // A vision pull, landed by an admin, rewrites the direction's fenced block.
src/actions/trusted_sources.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * Changing who is trusted (#8937): the owner's list, the kill switch and
3 * the revert of one source's landings. Only a forge admin who is a person
4 * may; each change is an event, so it is audited and the feed shows it.
5 */
6 import type { App } from "../app.ts";
7 import { listEveryRepo } from "../core/repos.ts";
8 import {
9 resumeFoldIn,
10 stopFoldIn,
11 trustSource,
12 untrust,
13 type TrustError,
14 type TrustedSource,
15 } from "../core/trusted_sources.ts";
16 import type { User } from "../core/users.ts";
17 import { revertSource } from "../crew/fold_in.ts";
18 import { err, ok, type Result } from "../kernel/result.ts";
19
20 const mayChange = (actor: User | undefined): actor is User =>
21 actor !== undefined && actor.isAdmin && actor.kind !== "agent";
22
23 export function addTrust(
24 app: App,
25 actor: User | undefined,
26 input: { host: string; account: string; until: string },
27 ): Result<TrustedSource, TrustError | "not_found"> {
28 if (!mayChange(actor)) return err("not_found");
29 const made = trustSource(app.db, { ...input, setBy: actor.username });
30 if (typeof made === "string") return err(made);
31 app.bus.append({
32 kind: "trust.set",
33 actorId: actor.id,
34 payload: { account: made.account, host: made.host, until: made.until },
35 });
36 return ok(made);
37 }
38
39 export function removeTrust(app: App, actor: User | undefined, sourceId: string): Result<TrustedSource, "not_found"> {
40 if (!mayChange(actor)) return err("not_found");
41 const gone = untrust(app.db, sourceId);
42 if (gone === undefined) return err("not_found");
43 app.bus.append({ kind: "trust.removed", actorId: actor.id, payload: { account: gone.account, host: gone.host } });
44 return ok(gone);
45 }
46
47 /** The kill switch: all folding in stops at once, and resumes on one action too. */
48 export function setFoldInStopped(app: App, actor: User | undefined, stopped: boolean): Result<boolean, "not_found"> {
49 if (!mayChange(actor)) return err("not_found");
50 if (stopped) stopFoldIn(app.db, actor.username);
51 else resumeFoldIn(app.db);
52 app.bus.append({ kind: stopped ? "foldin.stopped" : "foldin.resumed", actorId: actor.id, payload: {} });
53 return ok(stopped);
54 }
55
56 /** Open a revert for everything one source landed, in every repository. */
57 export async function revertBySource(
58 app: App,
59 actor: User | undefined,
60 host: string,
61 account: string,
62 ): Promise<Result<{ opened: number; refused: number }, "not_found">> {
63 if (!mayChange(actor)) return err("not_found");
64 let opened = 0;
65 let refused = 0;
66 for (const repo of listEveryRepo(app.db)) {
67 const r = await revertSource(app, actor, repo, host, account);
68 opened += r.opened.length;
69 refused += r.refused.length;
70 }
71 app.bus.append({ kind: "trust.reverted", actorId: actor.id, payload: { account, host, opened, refused } });
72 return ok({ opened, refused });
73 }
src/actions/upstream_follow.ts +15
@@ -44,6 +44,7 @@ import { isSafeOid, isSafeRef } from "../git/guard.ts";
4444 import { aheadCount, mergeBase, mergeable, resolveOid, writeRef } from "../git/merge.ts";
4545 import { REMOTE_PROTOCOLS } from "../git/remote-env.ts";
4646 import { repoDir } from "../git/repos.ts";
47 import { ownHost, recordPullSource, sourceOfUrl, takeable, viaOf } from "../core/trusted_sources.ts";
4748 import { err, ok, type Result } from "../kernel/result.ts";
4849 import { providerForOrigin } from "../origins/credential.ts";
4950 import { redact, secretsIn } from "../origins/credentials.ts";
@@ -301,6 +302,12 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr
301302 return ok({ tip: staged, behind, outcome: "none" });
302303 }
303304
305 // Work that already landed here and came back round a ring of forges is not taken again (#8937).
306 const seenVia = viaOf((await runGitCapture(["log", "-1", "--format=%B", staged], { cwd: dir })).stdout);
307 if (!takeable(seenVia, ownHost(app.config))) {
308 noteCheck(app.db, repo.id, { at, tip: staged, behind: 0, error: "" });
309 return ok({ tip: staged, behind: 0, outcome: "none" });
310 }
304311 const merged = await mergeable(dir, main, staged);
305312 if (!merged.ok) {
306313 if (merged.error.kind !== "conflict") return failed("git");
@@ -323,6 +330,12 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr
323330 const known = pullForBranch(app.db, repo.id, name);
324331 const pending = follow.pendingId === null ? undefined : getIssueById(app.db, follow.pendingId);
325332 const open = pending !== undefined && pending.kind === "pull" && pending.state === "open";
333 // Where the work came from, so a landing can say, and a trusted source's folds in by itself.
334 // Only a pull this follow opened is stamped (a person's or a guest's under the same branch name never is).
335 const from = sourceOfUrl(follow.url);
336 const came = (pullId: string): void => {
337 if (from !== undefined) recordPullSource(app.db, { pullId, ...from, via: seenVia });
338 };
326339 let number: number;
327340 if (known !== undefined) {
outdated

HIGH: this stamps a trusted source on any pull matching the branch name, including a person's or guest's pull. Stamp only pulls this follow opened, and do not overwrite.

328341 // This tip was proposed already, whatever came of it: nothing new.
@@ -330,12 +343,14 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr
330343 } else if (open) {
331344 // The upstream moved on: the open pull follows it instead of a second opening.
332345 retargetPull(app.db, pending.id, name, staged);
346 came(pending.id);
333347 await writeRef(dir, `refs/pull/${pending.number}/head`, staged);
334348 updateIssueText(app.db, pending.id, text);
335349 number = pending.number;
336350 } else {
337351 const opened = await openPull(app, by, repo, { ...text, baseBranch: repo.defaultBranch, headBranch: name });
338352 if (!opened.ok) return failed("git", `The proposal could not be opened: ${opened.error}`);
353 came(opened.value.id);
339354 noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "", pendingId: opened.value.id, opened: true });
340355 return ok({ tip: staged, behind, outcome: "proposed", pull: opened.value.number });
341356 }
src/core/event_audience.ts +3
@@ -169,6 +169,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
169169 kind.startsWith("clog.") ||
170170 // What the throughput keeper did to lift a brake is the owner's too (`flow/keeper.ts`).
171171 kind.startsWith("keeper.") ||
172 // Who is trusted to fold in, and the switch that stops it, are the owner's (`actions/trusted_sources.ts`).
173 kind.startsWith("trust.") ||
174 kind.startsWith("foldin.") ||
172175 // Events the retired FLOW ONLY switch wrote stay the admins' to hear.
173176 kind === "flow_only.cleared" ||
174177 // A report from outside waits for a person who decides; its arrival is
src/core/migration-pins/20261103160000-trusted-sources.pin added +1
@@ -0,0 +1,1 @@
1 87833aad18c42e1ed7a8f5bcef1cabfd61257af51c205970898ac142add0a0c1
src/core/migrations/20261103160000-trusted-sources.ts added +28
@@ -0,0 +1,28 @@
1 // Trusted sources (#8937): who, on which forge, until when, set by whom;
2 // where each pull from another forge came from; and the one switch that
3 // stops all folding in. Additive; the list starts empty and the owner fills it.
4 export default `
5 CREATE TABLE trusted_source (
6 id TEXT PRIMARY KEY,
7 host TEXT NOT NULL,
8 account TEXT NOT NULL,
9 until TEXT NOT NULL,
10 set_by TEXT NOT NULL,
11 created_at TEXT NOT NULL,
12 warned_at TEXT,
13 UNIQUE (host, account)
14 );
15 CREATE TABLE pull_source (
16 pull_id TEXT PRIMARY KEY,
17 host TEXT NOT NULL,
18 account TEXT NOT NULL,
19 via TEXT NOT NULL DEFAULT '',
20 received_at TEXT NOT NULL
21 );
22 CREATE INDEX pull_source_by_source ON pull_source (host, account);
23 CREATE TABLE fold_in_stop (
24 id INTEGER PRIMARY KEY CHECK (id = 1),
25 by TEXT NOT NULL,
26 at TEXT NOT NULL
27 );
28 `;
src/core/pull_trust.ts +3
@@ -22,6 +22,7 @@ import { getIssueById, getPull, type Pull } from "./pulls.ts";
2222 import { canWrite, getRepoById, onCrew, type Repo } from "./repos.ts";
2323 import { listReviews } from "./reviews.ts";
2424 import { runGrantedAt } from "./run_grant.ts";
25 import { foldsIn } from "./trusted_sources.ts";
2526 import { getUserById, type User } from "./users.ts";
2627
2728 const trusted = (db: Database, repo: Repo, user: User | undefined): boolean =>
@@ -40,6 +41,8 @@ export function pullMayRun(
4041 headOid: string,
4142 ): boolean {
4243 const proposed = isProposed(db, pull);
44 // A trusted source's code runs its checks like the crew's (#8937).
45 if (foldsIn(db, pull.id)) return true;
4346 if (!proposed && !pull.crossRepo && pull.originHeadRepo === null) return true;
4447 if (!proposed && pull.crossRepo && pull.originHeadRepo === null) {
4548 const author = getUserById(db, pull.authorId);
src/core/trusted_sources.ts added +252
@@ -0,0 +1,252 @@
1 /**
2 * Trusted sources (#8937): an account on a named forge, until a date, set by
3 * a person. An account name means nothing without its forge — the same name
4 * elsewhere is a stranger — so a source is always the pair. Trust ends by
5 * itself: past `until` a source is no longer listed as active and its work
6 * waits for review like anyone's. Pure data, no authz, no events;
7 * `actions/trusted_sources.ts` decides who may change it and audits it.
8 */
9 import type { Database } from "bun:sqlite";
10 import { id } from "../kernel/id.ts";
11
12 export interface TrustedSource {
13 readonly id: string;
14 readonly host: string;
15 readonly account: string;
16 readonly until: string;
17 readonly setBy: string;
18 readonly createdAt: string;
19 readonly warnedAt: string | null;
20 }
21
22 interface Row {
23 id: string;
24 host: string;
25 account: string;
26 until: string;
27 set_by: string;
28 created_at: string;
29 warned_at: string | null;
30 }
31
32 const toSource = (r: Row): TrustedSource => ({
33 id: r.id,
34 host: r.host,
35 account: r.account,
36 until: r.until,
37 setBy: r.set_by,
38 createdAt: r.created_at,
39 warnedAt: r.warned_at,
40 });
41
42 /** Days before expiry that the note is sent. */
43 export const WARN_DAYS = 3;
44 const DAY_MS = 86_400_000;
45
46 const HOST = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?(:\d{1,5})?$/;
47 const ACCOUNT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
48
49 /** A forge's name as a source is kept: lowercase host, no scheme, no path. */
50 export function cleanHost(raw: string): string | undefined {
51 const text = raw.trim().toLowerCase();
52 const host = URL.parse(text)?.host || text.replace(/^[a-z]+:\/\//, "").replace(/\/.*$/, "");
53 return HOST.test(host) ? host : undefined;
54 }
55
56 export function cleanAccount(raw: string): string | undefined {
57 const name = raw.trim().replace(/^@/, "").toLowerCase();
58 return ACCOUNT.test(name) ? name : undefined;
59 }
60
61 export type TrustError = "bad_host" | "bad_account" | "bad_date" | "in_the_past";
62
63 /** Keep (or renew) a source. The end is a calendar date, read as the end of that UTC day. */
64 export function trustSource(
65 db: Database,
66 input: { host: string; account: string; until: string; setBy: string },
67 now: Date = new Date(),
68 ): TrustedSource | TrustError {
69 const host = cleanHost(input.host);
70 if (host === undefined) return "bad_host";
71 const account = cleanAccount(input.account);
72 if (account === undefined) return "bad_account";
73 if (!/^\d{4}-\d{2}-\d{2}$/.test(input.until)) return "bad_date";
74 const end = new Date(`${input.until}T23:59:59.999Z`);
75 if (Number.isNaN(end.getTime())) return "bad_date";
76 if (end.getTime() <= now.getTime()) return "in_the_past";
77 const until = end.toISOString();
78 db.query(
79 `INSERT INTO trusted_source (id, host, account, until, set_by, created_at)
80 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
81 ON CONFLICT (host, account)
82 DO UPDATE SET until = excluded.until, set_by = excluded.set_by, warned_at = NULL`,
83 ).run(id(), host, account, until, input.setBy, now.toISOString());
84 return sourceOf(db, host, account)!;
85 }
86
87 export function sourceOf(db: Database, host: string, account: string): TrustedSource | undefined {
88 const row = db
89 .query<Row, [string, string]>("SELECT * FROM trusted_source WHERE host = ?1 AND account = ?2")
90 .get(host.toLowerCase(), account.toLowerCase());
91 return row === null ? undefined : toSource(row);
92 }
93
94 export function untrust(db: Database, sourceId: string): TrustedSource | undefined {
95 const row = db.query<Row, [string]>("SELECT * FROM trusted_source WHERE id = ?1").get(sourceId);
96 if (row === null) return undefined;
97 db.query("DELETE FROM trusted_source WHERE id = ?1").run(sourceId);
98 return toSource(row);
99 }
100
101 /** Every entry, expired ones included (the page shows them as lapsed), soonest end first. */
102 export function listTrusted(db: Database): TrustedSource[] {
103 return db.query<Row, []>("SELECT * FROM trusted_source ORDER BY until, host, account").all().map(toSource);
104 }
105
106 /** Whether the account on that forge is trusted at `now`. */
107 export function isTrusted(db: Database, host: string, account: string, now: Date = new Date()): boolean {
108 const source = sourceOf(db, host, account);
109 return source !== undefined && source.until > now.toISOString();
110 }
111
112 /** Entries that end within the warning window and have not been told about yet. */
113 export function dueWarning(db: Database, now: Date = new Date()): TrustedSource[] {
114 const soon = new Date(now.getTime() + WARN_DAYS * DAY_MS).toISOString();
115 return db
116 .query<Row, [string, string]>(
117 "SELECT * FROM trusted_source WHERE warned_at IS NULL AND until > ?1 AND until <= ?2",
118 )
119 .all(now.toISOString(), soon)
120 .map(toSource);
121 }
122
123 export function markWarned(db: Database, sourceId: string, now: Date = new Date()): void {
124 db.query("UPDATE trusted_source SET warned_at = ?2 WHERE id = ?1").run(sourceId, now.toISOString());
125 }
126
127 /** Whether the entry ends within the warning window (or already has). */
128 export const endsSoon = (s: TrustedSource, now: Date = new Date()): boolean =>
129 Date.parse(s.until) - now.getTime() <= WARN_DAYS * DAY_MS;
130
131 // --- the kill switch -------------------------------------------------------
132
133 export function foldInStopped(db: Database): { by: string; at: string } | undefined {
134 const row = db.query<{ by: string; at: string }, []>("SELECT by, at FROM fold_in_stop WHERE id = 1").get();
135 return row ?? undefined;
136 }
137
138 export function stopFoldIn(db: Database, by: string, now: Date = new Date()): void {
139 db.query("INSERT OR REPLACE INTO fold_in_stop (id, by, at) VALUES (1, ?1, ?2)").run(by, now.toISOString());
140 }
141
142 export function resumeFoldIn(db: Database): void {
143 db.query("DELETE FROM fold_in_stop WHERE id = 1").run();
144 }
145
146 // --- where a pull came from ------------------------------------------------
147
148 export interface PullSource {
149 readonly pullId: string;
150 readonly host: string;
151 readonly account: string;
152 /** Forges the work passed through before it reached this one, oldest first. */
153 readonly via: readonly string[];
154 }
155
156 /** Where a pull came from; the first word stands, a later call never rewrites it. */
157 export function recordPullSource(
158 db: Database,
159 input: { pullId: string; host: string; account: string; via: readonly string[] },
160 now: Date = new Date(),
161 ): void {
162 db.query(
163 `INSERT OR IGNORE INTO pull_source (pull_id, host, account, via, received_at)
164 VALUES (?1, ?2, ?3, ?4, ?5)`,
165 ).run(input.pullId, input.host.toLowerCase(), input.account.toLowerCase(), input.via.join(","), now.toISOString());
166 }
167
168 export function pullSourceOf(db: Database, pullId: string): PullSource | undefined {
169 const row = db
170 .query<{ host: string; account: string; via: string }, [string]>(
171 "SELECT host, account, via FROM pull_source WHERE pull_id = ?1",
172 )
173 .get(pullId);
174 return row === null
175 ? undefined
176 : { pullId, host: row.host, account: row.account, via: row.via === "" ? [] : row.via.split(",") };
177 }
178
179 /** `account@host` of the pulls (by number, in one repository) that came from another forge. */
180 export function sourcesOfPulls(db: Database, repoId: string, numbers: readonly number[]): Map<number, string> {
181 const out = new Map<number, string>();
182 if (numbers.length === 0) return out;
183 const rows = db
184 .query<{ number: number; account: string; host: string }, (string | number)[]>(
185 `SELECT i.number, s.account, s.host FROM issue i JOIN pull_source s ON s.pull_id = i.id
186 WHERE i.repo_id = ?1 AND i.number IN (${numbers.map((_, n) => `?${n + 2}`).join(",")})`,
187 )
188 .all(repoId, ...numbers);
189 for (const r of rows) out.set(r.number, `${r.account}@${r.host}`);
190 return out;
191 }
192
193 /** Pull ids that came from one source, newest first. */
194 export function pullIdsFrom(db: Database, host: string, account: string): string[] {
195 return db
196 .query<{ pull_id: string }, [string, string]>(
197 "SELECT pull_id FROM pull_source WHERE host = ?1 AND account = ?2 ORDER BY received_at DESC",
198 )
199 .all(host.toLowerCase(), account.toLowerCase())
200 .map((r) => r.pull_id);
201 }
202
203 // --- a landing says where it came from -------------------------------------
204
205 /**
206 * Whether a pull's source is trusted right now and folding in is not
207 * stopped: the one question the crew's landing path asks. Trust skips the
208 * person's review and nothing else.
209 */
210 export function foldsIn(db: Database, pullId: string, now: Date = new Date()): boolean {
211 const from = pullSourceOf(db, pullId);
212 return from !== undefined && foldInStopped(db) === undefined && isTrusted(db, from.host, from.account, now);
213 }
214
215 /**
216 * The trailers a landing from another forge carries. `Landed-via` lists
217 * every forge the work passed through, this one last: a forge that finds
218 * itself in the list will not take the work again (`takeable`).
219 */
220 export function sourceTrailers(from: PullSource, selfHost: string): string {
221 const via = [...from.via, from.host, selfHost.toLowerCase()].filter((h, i, all) => all.indexOf(h) === i);
222 return `Landed-from: ${from.account}@${from.host}\nLanded-via: ${via.join(",")}`;
223 }
224
225 /** `Landed-via` read back from a commit message; empty when it has none. */
226 export function viaOf(message: string): string[] {
227 const line = /^Landed-via: ([a-z0-9.,:-]{1,500})$/m.exec(message);
228 return line === null ? [] : line[1]!.split(",").filter((h) => HOST.test(h));
229 }
230
231 /** Work that already passed through this forge is not taken in again: that is how a ring of forges stops. */
232 export const takeable = (via: readonly string[], selfHost: string): boolean => !via.includes(selfHost.toLowerCase());
233
234 /** This forge's own name as other forges' trust lists spell it. */
235 export function ownHost(config: { url?: string | undefined; host?: string | undefined }): string {
236 return (config.url === undefined ? undefined : URL.parse(config.url)?.host.toLowerCase()) ?? (config.host ?? "localhost").toLowerCase();
237 }
238
239 /**
240 * Who an upstream's address names: the forge, and the account that owns the
241 * repository (`https://host/account/name.git`). This is the owner of the
242 * followed repository, not a verified commit author: trust here means "work
243 * from the repository this account owns", and commits in it keep whatever
244 * author they carry.
245 */
246 export function sourceOfUrl(url: string): { host: string; account: string } | undefined {
247 const parsed = URL.parse(url);
248 if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) return undefined;
249 const host = cleanHost(parsed.host);
250 const account = cleanAccount(parsed.pathname.split("/").filter((s) => s !== "")[0] ?? "");
251 return host === undefined || account === undefined ? undefined : { host, account };
252 }
src/crew/autonomy.ts +43 −4
@@ -132,6 +132,9 @@ import {
132132 landsOnItsOwn,
133133 markReaders,
134134 } from "./direct.ts";
135 import { foldsIn } from "../core/trusted_sources.ts";
136 import { foldWall } from "./fold_walls.ts";
137 import { warnExpiring } from "./fold_in.ts";
135138 import { crewOffInPreview } from "./preview.ts";
136139 import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts";
137140 import { recheckRedApproved } from "./red_approved.ts";
@@ -1150,7 +1153,7 @@ function answeredWithoutChange(
11501153 * was turned on, or one the event missed — land on the tick, so nothing
11511154 * green waits on chance.
11521155 */
1153 export async function sweepGreen(app: App, repo: Repo): Promise<number> {
1156 export async function sweepGreen(app: App, repo: Repo, now: Date = new Date()): Promise<number> {
11541157 if (repo.autonomy !== "autonomous") return 0;
11551158 // Admitted one by one, queued together, oldest first: the green pulls of
11561159 // one sweep are one candidate and one gate (#2036).
@@ -1158,7 +1161,7 @@ export async function sweepGreen(app: App, repo: Repo): Promise<number> {
11581161 // Why each approved pull waits, for the tick line (#3055).
11591162 const waits: Wait[] = [];
11601163 for (const pull of listOpenPulls(app.db, repo.id)) {
1161 const admitted = await admit(app, repo, pull);
1164 const admitted = await admit(app, repo, pull, now);
11621165 if (admitted.ok) entries.push(admitted.value);
11631166 const seen = reviewSummary(app.db, pull.id, pull.headOid);
11641167 if (seen.approvals > 0 && seen.changesRequested === 0)
@@ -1281,6 +1284,12 @@ export async function autonomyTick(
12811284 } catch (error) {
12821285 log.error("stranded sweep failed", { error: String(error) });
12831286 }
1287 // Three days before a trusted source lapses, a note (#8937).
1288 try {
1289 warnExpiring(app, now);
1290 } catch (error) {
1291 log.error("trust note failed", { error: String(error) });
1292 }
12841293 for (const repo of listAutonomousRepos(app.db)) {
12851294 // An archived repository is put away: the crew starts nothing in it.
12861295 if (repo.archivedAt !== null) continue;
@@ -1919,10 +1928,14 @@ async function reviewed(
19191928 app: App,
19201929 repo: Repo,
19211930 pull: Pull,
1931 now: Date = new Date(),
19221932 ): Promise<"pass" | "hold" | "lapsed"> {
19231933 const seen = reviewSummary(app.db, pull.id, pull.headOid);
19241934 if (seen.changesRequested > 0) return "hold";
19251935 if (seen.approvals > 0) return "pass";
1936 // A trusted source's pull needs no person's review (#8937); its check, the
1937 // boot proof and the walls above still stood before this point.
1938 if (foldsIn(app.db, pull.id, now)) return "pass";
19261939 // An agent's pull ships only on an approving review — the crew's reviewer
19271940 // or a person's. With no reviewer, or a review that lapsed, it waits for
19281941 // a person (`waitsForPerson` puts it where a person looks); never by lapse.
@@ -1965,6 +1978,7 @@ async function admit(
19651978 app: App,
19661979 repo: Repo,
19671980 pull: Pull,
1981 now: Date = new Date(),
19681982 ): Promise<Result<Queued, Wait>> {
19691983 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return err("off");
19701984 if (pull.state !== "open" || pull.mergedAt !== null) return err("closed");
@@ -1980,7 +1994,7 @@ async function admit(
19801994 // Only work the crew wrote, or a writer's, lands on green by itself: a
19811995 // reader's pull — from a fork, say — waits for a person, outsiders
19821996 // switch or not, and is never sent to the crew's reviewer (#619).
1983 if (!landsOnItsOwn(app.db, repo, pull)) return err("outsider");
1997 if (!landsOnItsOwn(app.db, repo, pull, now)) return err("outsider");
19841998 const by = getUserById(app.db, repo.autonomyBy);
19851999 if (!by) return err("off");
19862000 // The newest run of each kind is what the head says: a red that a re-run
@@ -2070,6 +2084,27 @@ async function admit(
20702084 }
20712085 return err("wall");
20722086 }
2087 // A trusted source's pull that touches secrets, credentials, permissions,
2088 // the contract or release machinery waits for a person like a guest's.
2089 const folded = await foldWall(app, repo, pull, baseTip, now);
2090 if (folded.length > 0) {
2091 const earlier = latestDecision(app.db, pull.id, "land");
2092 const told =
2093 earlier?.answer === "wall" &&
2094 (earlier.state as { head?: unknown } | null)?.head === pull.headOid;
2095 if (!told)
2096 recordDecision(app.db, {
2097 kind: "land",
2098 repoId: repo.id,
2099 issueId: pull.id,
2100 state: { head: pull.headOid, cells: folded, plain: folded, unsound: false },
2101 answer: "wall",
2102 p: 1,
2103 by: "wall",
2104 ms: 0,
2105 });
2106 return err("wall");
2107 }
20732108 // One gate for every pull, whoever wrote it: when the crew has a
20742109 // reviewer, a green head is queued for review and waits for an approval
20752110 // on that very head (a person's counts the same). A push is a new head,
@@ -2077,7 +2112,7 @@ async function admit(
20772112 // long it takes — with no reviewer, or after one lapsed, a person's
20782113 // (#597). Only a person's own pull lands without a reviewer, or after a
20792114 // review lapsed.
2080 const review = await reviewed(app, repo, pull);
2115 const review = await reviewed(app, repo, pull, now);
20812116 if (review === "hold") return err("review");
20822117 // The full tier, once, on the queue's candidate (#1198, #2036). Red is
20832118 // told to the pull once per head and base, and the pull is out of the
@@ -2214,7 +2249,11 @@ export async function landingStrategy(
22142249 repo: Repo,
22152250 pull: Pull,
22162251 baseOid: string,
2252 now: Date = new Date(),
22172253 ): Promise<"merge" | "squash"> {
2254 // A trusted source's work lands as a true merge: its commits stay
2255 // ancestors of main and each author keeps their name (#8937).
2256 if (foldsIn(app.db, pull.id, now)) return "merge";
22182257 return (await carriesUpstream(
22192258 app,
22202259 repo,
src/crew/direct.ts +5 −1
@@ -19,6 +19,7 @@ import { crewRole } from "../core/agents.ts";
1919 import { canRead, canWrite, type Repo } from "../core/repos.ts";
2020 import type { Issue } from "../core/issues.ts";
2121 import { isProposed } from "../core/pull_trust.ts";
22 import { foldsIn } from "../core/trusted_sources.ts";
2223 import { openPullsClosing, type Pull } from "../core/pulls.ts";
2324 import { getUserById, getUserByName, type User } from "../core/users.ts";
2425
@@ -223,7 +224,10 @@ export function unadoptedAmong(
223224 * outsiders switch says — the switch lets a reader's words reach the
224225 * crew, not a reader's code reach the default branch unread by a person.
225226 */
226 export function landsOnItsOwn(db: Database, repo: Repo, pull: Pull): boolean {
227 export function landsOnItsOwn(db: Database, repo: Repo, pull: Pull, now: Date = new Date()): boolean {
228 // A trusted source's pull (#8937) lands on its checks, wall and boot proof
229 // alone; trust skips the person and nothing else.
230 if (foldsIn(db, pull.id, now)) return true;
227231 if (pull.originAuthor !== null) return false;
228232 // A child's code, whoever holds its token: a grant lets it run, a person's
229233 // review lands it.
src/crew/fold_in.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * Folding in (#8937): work from a trusted source flows to the default
3 * branch without a person's review, and without skipping anything else —
4 * its check, the boot proof and the walls still stand. A branch becomes a
5 * pull only through the upstream follow (`actions/upstream_follow.ts`),
6 * which records where it came from; the autonomy tick's own `sweepGreen`
7 * then admits it like the crew's. This module is what is left: the note
8 * before a trust lapses and the revert of one source's work. The walls are
9 * in `fold_walls.ts`.
10 */
11 import type { App } from "../app.ts";
12 import { getIssueById } from "../core/issues.ts";
13 import { getPull } from "../core/pulls.ts";
14 import type { Repo } from "../core/repos.ts";
15 import { dueWarning, markWarned, pullIdsFrom } from "../core/trusted_sources.ts";
16 import type { User } from "../core/users.ts";
17 import { revertPull } from "../actions/revert.ts";
18
19 /** The note 3 days before a trust ends: an event the owner's feed shows, once per entry. */
20 export function warnExpiring(app: App, now: Date = new Date()): number {
21 const due = dueWarning(app.db, now);
22 for (const source of due) {
23 app.bus.append({
24 kind: "trust.expiring",
25 payload: { account: source.account, host: source.host, until: source.until },
26 });
27 markWarned(app.db, source.id, now);
28 }
29 return due.length;
30 }
31
32 /**
33 * A bad landing reverts by source: every merged pull of one source in this
34 * repository becomes a revert pull, newest first, for a person to land.
35 * Which pulls were reverted, and which could not be.
36 */
37 export async function revertSource(
38 app: App,
39 actor: User,
40 repo: Repo,
41 host: string,
42 account: string,
43 ): Promise<{ opened: number[]; refused: number[] }> {
44 const opened: number[] = [];
45 const refused: number[] = [];
46 for (const id of pullIdsFrom(app.db, host, account)) {
47 const issue = getIssueById(app.db, id);
48 if (issue === undefined || issue.repoId !== repo.id) continue;
49 const pull = getPull(app.db, repo.id, issue.number);
50 if (pull === undefined || pull.mergedAt === null) continue;
51 const reverted = await revertPull(app, actor, repo, pull, `Reverts #${pull.number}, folded in from ${account}@${host}.`);
52 (reverted.ok ? opened : refused).push(pull.number);
53 }
54 return { opened, refused };
55 }
src/crew/fold_walls.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * The walls a trusted source's pull still stops at (#8937): secrets and
3 * credentials, permissions and the contract's walls, release machinery.
4 * Trust skips the person's review, never these.
5 */
6 import type { App } from "../app.ts";
7 import { pullDir, type Pull } from "../core/pulls.ts";
8 import type { Repo } from "../core/repos.ts";
9 import { foldsIn } from "../core/trusted_sources.ts";
10 import { runGitCapture } from "../git/env.ts";
11
12 /**
13 * Paths a person decides on whoever wrote them: secrets and credentials,
14 * who may do what, the contract's walls, the machinery that releases and
15 * the machinery that enforces these very walls (fold-in, autonomy, the
16 * trust list's actions and page, the auth routes, event audiences) and
17 * every migration. Each rule says why in the words a person reads on the card.
18 */
19 const WALLS: readonly { readonly test: RegExp; readonly why: string }[] = [
20 { test: /(^|\/)\.env([./]|$)|(^|\/)(secrets?|credentials?)([./_-]|$)|\.(pem|key|p12|pfx)$|(^|\/)id_(rsa|ed25519|ecdsa)/i, why: "secrets or credentials" },
outdated

HIGH: crew/fold_in is walled but fold_walls.ts, autonomy.ts and actions/trusted_sources.ts are not. A trusted pull can remove the walls. Wall these, and every migration, or switch to an allowlist.

21 { test: /^src\/(auth|origins\/credential|kernel\/secret|core\/(secret|tokens|keys|sessions|passkeys|run_token|run_grant|proposal_token))/, why: "secrets or credentials" },
22 { test: /^src\/(contract|core\/(roles|access_fingerprint|pull_trust|trusted_sources|event_audience)|crew\/(fold_|autonomy|direct|finishing)|http\/(run_gate|[^/]*auth[^/]*|settings_routes|admin_gate)|actions\/(access|app_members|egress_grants|model_grant|trusted_sources|pulls|upstream_follow)|web\/pages\/trusted|tui\/screens\/trusted)/, why: "permissions or the contract's walls" },
23 { test: /^(src\/(release|keeper)\/|src\/core\/migration|scripts\/(gate|check|cut-release|roll|dark-roll|rehearse-roll|roll-preflight|pin-migrations)\.ts$|Dockerfile$|charts\/|infra\/|forge\.json$|package\.json$|bun\.lock$|bunfig\.toml$|\.github\/)/, why: "release machinery" },
24 ];
25
26 /**
27 * Where a trusted source's work may land alone: the code, its tests and the
28 * docs. Any other path (the repository's root files, scripts, charts) is one
29 * nobody listed, and unlisted waits for a person.
30 */
31 const ALLOWED = /^(src\/|test\/|docs\/|examples\/|README\.md$)/;
32
33 /** What the walls hold a set of files for, as plain sentences; empty when it touches none. */
34 export function wallsTouched(files: readonly string[]): string[] {
35 const reasons = new Set<string>();
36 for (const file of files) {
37 const walled = WALLS.filter((w) => w.test.test(file));
38 for (const w of walled) reasons.add(w.why);
39 if (walled.length === 0 && !ALLOWED.test(file)) reasons.add("a path a trusted source's work may not change alone");
40 }
41 return [...reasons].map((why) => `a trusted source's pull touches ${why}`);
42 }
43
44 /** The files a pull changes against its base, or undefined when git could not say. */
45 export async function touchedFiles(dir: string, base: string, head: string): Promise<string[] | undefined> {
46 const out = await runGitCapture(["diff", "--name-only", "-z", "--no-renames", `${base}...${head}`, "--"], { cwd: dir });
47 if (out.code !== 0) return undefined;
48 return out.stdout.split("\0").filter((f) => f !== "");
49 }
50
51 /**
52 * What waits for a person on a trusted pull even though its source is
53 * trusted: the walls. Empty for a guest's pull (it waits for a person
54 * anyway) and for a trusted pull that touches none. A diff git cannot read
55 * is a wall too: unread is not clear.
56 */
57 export async function foldWall(app: App, repo: Repo, pull: Pull, baseTip: string, now: Date = new Date()): Promise<string[]> {
58 if (!foldsIn(app.db, pull.id, now)) return [];
59 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
60 const files = await touchedFiles(dir, baseTip, pull.headOid);
61 return files === undefined ? ["a trusted source's pull could not be read"] : wallsTouched(files);
62 }
63
src/http/settings_routes.ts +6
@@ -15,6 +15,7 @@ import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1515 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
1616 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1717 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
18 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
1819 import { usagePage } from "../web/pages/usage.ts";
1920 import { spendPage } from "../web/pages/spend-tab.ts";
2021 import {
@@ -112,6 +113,11 @@ import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts";
112113 import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts";
113114
114115 export function settingsRoutes(router: Router<Handler>, knownPatterns: () => readonly string[]): void {
116 router.add("GET", "/settings/trusted", trustedPage);
117 router.add("POST", "/settings/trusted", trustedAdd);
118 router.add("POST", "/settings/trusted/stop", trustedStop);
119 router.add("POST", "/settings/trusted/revert", trustedRevert);
120 router.add("POST", "/settings/trusted/:id/remove", trustedRemove);
115121 router.add("GET", "/settings/telemetry", telemetryPage);
116122 router.add("POST", "/settings/telemetry", telemetrySet);
117123 router.add("GET", "/settings", accountPage);
src/release/landings.ts +2
@@ -28,6 +28,8 @@ export interface Landing {
2828 readonly live: boolean;
2929 /** The bet whose scopes this pull built (#8713), set by the page that shows it. */
3030 readonly bet?: string;
31 /** `account@host` when a trusted source's work folded in (#8937), set by the page that shows it. */
32 readonly from?: string;
3133 }
3234
3335 export interface Landings {
src/tui/forge.ts +9
@@ -54,6 +54,7 @@ import { talkChatsScreen } from "./screens/talk_chats.ts";
5454 import { sshKeysScreen } from "./screens/sshkeys.ts";
5555 import { passkeysScreen } from "./screens/passkeys.ts";
5656 import { confirmsScreen } from "./screens/confirms.ts";
57 import { mayTrust, trustedScreen } from "./screens/trusted.ts";
5758 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5859 import { signInsScreen } from "./screens/signins.ts";
5960 import { originAppsScreen } from "./screens/originapps.ts";
@@ -317,6 +318,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
317318 hint: "what the forge's agent asked to do; y does it once",
318319 run: () => ctx.push(confirmsScreen(session, ctx)),
319320 });
321 // Who folds in without a person's review, and the kill switch (#8937): a forge admin's.
322 if (session.signedIn && mayTrust(session))
323 commands.push({
324 id: "trusted",
325 title: "Trusted sources",
326 hint: "who folds in without review; a trusts, x removes, s stops all, v reverts",
327 run: () => ctx.push(trustedScreen(session, ctx)),
328 });
320329 // The forge's agent keeps many conversations, as /talk lists them (#8814).
321330 if (session.signedIn && session.user?.kind !== "agent")
322331 commands.push({
src/tui/screens/resilience.ts +2 −1
@@ -255,11 +255,12 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
255255 const out: string[] = [];
256256 if (asking !== undefined) out.push(QUESTION[asking], "");
257257 else if (said !== "") out.push(...wrap(said, INNER), "");
258 // A refusal or a result of a change stays up here too, above where a prompt opens over the middle.
259 if (note !== "") out.push(...wrap(note, INNER), "");
258260 out.push(...wrap(backupsLine(f.app).says, INNER), ...verdictLines(f), "");
259261 for (const line of nightlyLines(f.app.config.dataDir)) out.push(...wrap(line, INNER));
260262 out.push("");
261263 out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER));
262 if (note !== "") out.push(...wrap(note, INNER));
263264 if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.", "t tests where snapshots go.");
264265 out.push("", "Snapshots, newest first:");
265266 if (snapshots === undefined) out.push(" reading…");
src/tui/screens/trusted.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * Trusted sources in the terminal (#8937), a forge admin's: the list
3 * Settings → Trusted sources has, with the same doors — `a` trusts an
4 * account on a forge until a date, `x` removes the one under the cursor, `s`
5 * is the kill switch (stops all folding in, or resumes it), `v` reverts what
6 * the one under the cursor landed. Each goes through `actions/trusted_sources.ts`,
7 * so it is the same check, the same audit and the same event as the web's.
8 * Anyone else is not offered it.
9 */
10
11 import { addTrust, removeTrust, revertBySource, setFoldInStopped } from "../../actions/trusted_sources.ts";
12 import { endsSoon, foldInStopped, listTrusted, type TrustError } from "../../core/trusted_sources.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, confirmScreen, drawList, promptScreen, runAsync, type ListRow } from "./common.ts";
20
21 const WORDS: Record<TrustError | "not_found", string> = {
22 bad_host: "That is not a forge address.",
23 bad_account: "That is not an account name.",
24 bad_date: "Give the end as a date, year-month-day.",
25 in_the_past: "That date has passed.",
26 not_found: "Only a forge admin changes who is trusted.",
27 };
28
29 /** Whether the palette offers the screen: a signed-in admin who is a person. */
30 export const mayTrust = (f: Session): boolean => f.user !== undefined && f.user.isAdmin && f.user.kind !== "agent";
31
32 export function trustedScreen(f: Session, ctx: Ctx): Screen {
33 const state = new ListState();
34 const entries = () => (mayTrust(f) ? listTrusted(f.db) : []);
35
36 const rows = (): ListRow[] => {
37 const now = new Date();
38 return entries().map((s) => ({

No test drives this screen (a/x/s/v and the non-admin refusal). Add one.

39 title: `${s.account} ${s.host}`,
40 meta:
41 s.until <= now.toISOString()
42 ? "lapsed, waits for review"
43 : `until ${s.until.slice(0, 10)}${endsSoon(s, now) ? " (ends soon)" : ""}`,
44 }));
45 };
46
47 const screen: Screen = {
48 title: "Trusted sources",
49 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "trust"), binding(["x"], "remove"), binding(["s"], "stop or resume"), binding(["v"], "revert landings")],
50 render(area, s) {
51 const c = ctx.theme.c;
52 const [head, body] = split(area, "v", [len(1), fill(1)]);
53 const stopped = foldInStopped(f.db);
54 const text = !mayTrust(f)
55 ? "Only a forge admin changes who is trusted."
56 : stopped !== undefined
57 ? `Folding in is stopped (by ${stopped.by}); s resumes it.`
58 : "Who folds in without a person's review; s stops all of it.";
59 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
60 drawList(s, body!, c, state, rows());
61 },
62 key(e) {
63 if (!mayTrust(f) || e.ctrl || e.alt) return false;
64 if (state.key(e, entries().length)) return true;
65 const user = f.user;
66 const at = entries()[state.index];
67 if (e.key === "a") {
68 ctx.push(
69 promptScreen(ctx, screen, {
70 title: "Trust a source",
71 label: "account forge until, e.g. jmoran git.open-platform.sh 2026-12-02",
72 hint: "account, forge, end date",
73 onSubmit: (raw) => {
74 const [account = "", host = "", until = ""] = raw.trim().split(/\s+/);
75 const made = addTrust(f.app, user, { account, host, until });
76 if (!made.ok) return WORDS[made.error];
77 ctx.toast(`${made.value.account} on ${made.value.host} is trusted until ${made.value.until.slice(0, 10)}.`, "success");
78 ctx.invalidate();
79 return undefined;
80 },
81 }),
82 );
83 return true;
84 }
85 if (e.key === "s") {
86 const stop = foldInStopped(f.db) === undefined;
87 ctx.push(
88 confirmScreen(ctx, screen, {
89 title: stop ? "Stop all folding in" : "Resume folding in",
90 body: [stop ? "Every trusted source's work waits for review until you resume." : "Trusted sources' work folds in again."],
91 actions: [{ label: "Cancel", variant: "ghost" }, { label: stop ? "Stop" : "Resume", variant: "default" }],
92 onChoose: (i) => {
93 if (i !== 1) return;
94 setFoldInStopped(f.app, user, stop);
95 ctx.toast(stop ? "Folding in is stopped." : "Folding in resumed.", "success");
96 ctx.invalidate();
97 },
98 }),
99 );
100 return true;
101 }
102 if (at === undefined) return false;
103 if (e.key === "x") {
104 ctx.push(
105 confirmScreen(ctx, screen, {
106 title: "Remove trust",
107 body: [`${at.account} on ${at.host} will wait for review.`],
108 actions: [{ label: "Keep", variant: "ghost" }, { label: "Remove", variant: "default" }],
109 onChoose: (i) => {
110 if (i !== 1) return;
111 const gone = removeTrust(f.app, user, at.id);
112 ctx.toast(gone.ok ? `${at.account} on ${at.host} now waits for review.` : WORDS.not_found, gone.ok ? "success" : "danger");
113 ctx.invalidate();
114 },
115 }),
116 );
117 return true;
118 }
119 if (e.key === "v") {
120 ctx.push(
121 confirmScreen(ctx, screen, {
122 title: "Revert landings",
123 body: [`Opens a revert pull for everything ${at.account} on ${at.host} landed, for you to land.`],
124 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Open reverts", variant: "default" }],
125 onChoose: (i) => {
126 if (i !== 1) return;
127 runAsync(ctx, async () => {
128 const done = await revertBySource(f.app, user, at.host, at.account);
129 ctx.toast(done.ok ? `${done.value.opened} revert pull${done.value.opened === 1 ? "" : "s"} opened.` : WORDS.not_found, done.ok ? "success" : "danger");
130 ctx.invalidate();
131 });
132 },
133 }),
134 );
135 return true;
136 }
137 return false;
138 },
139 };
140 return screen;
141 }
src/web/pages/changelog.ts +11 −3
@@ -9,6 +9,7 @@
99 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
1010 import type { Ctx } from "../../http/context.ts";
1111 import { betNamesOfPulls } from "../../core/bets.ts";
12 import { sourcesOfPulls } from "../../core/trusted_sources.ts";
1213 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
1314 import { newestLive, secondsWords } from "../../keeper/landed.ts";
1415 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
@@ -30,7 +31,7 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm
3031 return row({
3132 href: `/${l.repo}/pulls/${l.number}`,
3233 title: l.title,
33 meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}${l.bet === undefined ? null : html` · ${badge(l.bet, { variant: "outline" })}`}`,
34 meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}${l.bet === undefined ? null : html` · ${badge(l.bet, { variant: "outline" })}`}${l.from === undefined ? null : html` · folded in from ${l.from}`}`,
3435 trail: time(l.at),
3536 });
3637 }
@@ -184,8 +185,15 @@ export async function changelogJson(ctx: Ctx): Promise<Response> {
184185 */
185186 function withBets(ctx: Ctx, read: Landings, repo: Repo | undefined): { read: Landings; bets: string[]; bet: string } {
186187 if (repo === undefined) return { read, bets: [], bet: "" };
187 const names = betNamesOfPulls(ctx.app.db, repo.id, [...read.live, ...read.onItsWay].filter((l) => l.repo === read.own).map((l) => l.number));
188 const mark = (l: Landing): Landing => (l.repo === read.own && names.has(l.number) ? { ...l, bet: names.get(l.number)! } : l);
188 const numbers = [...read.live, ...read.onItsWay].filter((l) => l.repo === read.own).map((l) => l.number);
189 const names = betNamesOfPulls(ctx.app.db, repo.id, numbers);
190 const froms = sourcesOfPulls(ctx.app.db, repo.id, numbers);
191 const mark = (l: Landing): Landing => {
192 if (l.repo !== read.own) return l;
193 const bet = names.get(l.number);
194 const from = froms.get(l.number);
195 return { ...l, ...(bet === undefined ? {} : { bet }), ...(from === undefined ? {} : { from }) };
196 };
189197 const live = read.live.map(mark);
190198 const onItsWay = read.onItsWay.map(mark);
191199 const bets = [...new Set([...live, ...onItsWay].flatMap((l) => (l.bet === undefined ? [] : [l.bet])))].sort();
src/web/pages/origins.ts +7
@@ -93,6 +93,7 @@ export type AccountSection =
9393 | "spend"
9494 | "crew"
9595 | "accounts"
96 | "trusted"
9697 | "people"
9798 | "subscriptions"
9899 | "backups"
@@ -137,6 +138,12 @@ export function accountSections(
137138 icon: "bot" as const,
138139 current: current === "crew",
139140 },
141 {
142 label: "Trusted sources",
143 href: "/settings/trusted",
144 icon: "lock" as const,
145 current: current === "trusted",
146 },
140147 {
141148 label: "Subscriptions",
142149 href: "/settings/subscriptions",
src/web/pages/trusted.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * Settings → Trusted sources (#8937), for the owner: who may fold in without
3 * a person's review, until when; a one-click remove; the kill switch that
4 * stops all folding in; and the revert of one source's landings.
5 */
6 import { addTrust, removeTrust, revertBySource, setFoldInStopped } from "../../actions/trusted_sources.ts";
7 import {
8 endsSoon,
9 foldInStopped,
10 listTrusted,
11 type TrustError,
12 } from "../../core/trusted_sources.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
15 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
16 import { field, readForm } from "../form.ts";
17 import { html } from "../html.ts";
18 import { flashTo, page } from "../layout.ts";
19 import { badge, btn, card, icon, row, time } from "../ui.ts";
20 import { accountSections } from "./origins.ts";
21
22 function owner(ctx: Ctx): Response | undefined {
23 if (!ctx.auth.user) return signInFirst(ctx);
24 return ctx.auth.user.isAdmin && ctx.auth.user.kind !== "agent" ? undefined : notFoundPage(ctx);
25 }
26
27 const WORDS: Record<TrustError | "not_found", string> = {
28 bad_host: "That is not a forge address.",
29 bad_account: "That is not an account name.",
30 bad_date: "Give the end as a date, year-month-day.",
31 in_the_past: "That date has passed.",
32 not_found: "Not found.",
33 };
34
35 // GET /settings/trusted
36 export function trustedPage(ctx: Ctx): Response {
37 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
38 const denied = owner(ctx);
39 if (denied) return denied;
40 const rendering = performance.now();
41 const now = new Date();
42 const stopped = foldInStopped(ctx.app.db);
43 const entries = listTrusted(ctx.app.db);
44 const body = html`${card(
45 html`<div class="card-body"><p class="muted small">
46 Work from a trusted source flows in as it appears and lands by true merge when its checks pass,
47 with no person's review. It never skips the check, the boot proof or the walls: a pull touching
48 secrets, credentials, permissions, the contract or release machinery still waits for you. Anyone
49 else is a guest, and their pull waits on <a href="/review">review</a>. An account name means
50 nothing without its forge.
51 </p>
52 <form method="post" action="/settings/trusted/stop" class="row-fields">
53 <input type="hidden" name="stop" value="${stopped ? "0" : "1"}" />
54 ${stopped
55 ? html`${badge("Folding in is stopped", { variant: "outline" })}
56 <span class="muted small">by ${stopped.by}, <time datetime="${stopped.at}">${stopped.at.slice(0, 10)}</time></span>
57 ${btn("Resume folding in", { size: "sm" })}`
58 : btn("Stop all folding in", { size: "sm", variant: "outline" })}
59 </form></div>`,
60 { head: html`${icon("lock")}<h2>Fold-in</h2>` },
61 )}
62 ${card(
63 html`<ul class="list setting-list">
64 ${entries.length === 0
65 ? html`<li class="muted small">No one is trusted. Everyone waits for review.</li>`
66 : entries.map((s) => {
67 const lapsed = s.until <= now.toISOString();
68 return row({
69 lead: icon("user"),
70 title: html`${s.account} <span class="muted small mono">${s.host}</span>`,
71 meta: html`${lapsed ? "Lapsed, waits for review" : "Until"} ${time(s.until)}${
72 !lapsed && endsSoon(s, now) ? " (ends soon)" : ""
73 } · set by ${s.setBy}`,
74 trail: html`<form class="row-fields" method="post" action="/settings/trusted/${s.id}/remove">
75 ${btn("Remove", { size: "sm", variant: "outline", label: `Remove ${s.account} on ${s.host}` })}
76 </form>
77 <form class="row-fields" method="post" action="/settings/trusted/revert">
78 <input type="hidden" name="host" value="${s.host}" />
79 <input type="hidden" name="account" value="${s.account}" />
80 <label class="muted small"><input type="checkbox" name="confirm" value="1" required /> Open reverts</label>
81 ${btn("Revert landings", { size: "sm", variant: "outline", label: `Revert what ${s.account} on ${s.host} landed` })}
82 </form>`,
83 });
84 })}
85 </ul>`,
86 { head: html`${icon("users")}<h2>Trusted sources</h2>`, pad: false },
87 )}
88 ${card(
89 html`<form method="post" action="/settings/trusted" class="stack">
90 <label>Account <input class="input" name="account" required maxlength="64" autocomplete="off" /></label>
91 <label>Forge <input class="input" name="host" required maxlength="260" placeholder="git.open-platform.sh" autocomplete="off" /></label>
92 <label>Until <input class="input" name="until" type="date" required /></label>
93 ${btn("Trust", { icon: "check" })}
94 </form>`,
95 { head: html`${icon("plus")}<h2>Trust a source</h2>` },
96 )}`;
97 notePhase(ctx.request, "render", performance.now() - rendering);
98 return page({
99 ctx,
100 title: "Trusted sources",
101 body: html`<div class="page-head"><h1>Trusted sources</h1></div>
102 ${settingsLayout(accountSections("trusted", { admin: true }), body, "Account settings")}`,
103 });
104 }
105
106 // POST /settings/trusted
107 export async function trustedAdd(ctx: Ctx): Promise<Response> {
108 const denied = owner(ctx);
109 if (denied) return denied;
110 const form = await readForm(ctx, "admin");
111 if (form instanceof Response) return form;
112 const made = addTrust(ctx.app, ctx.auth.user, {
113 host: field(form, "host"),
114 account: field(form, "account"),
115 until: field(form, "until"),
116 });
117 return flashTo(
118 ctx,
119 "/settings/trusted",
120 made.ok
121 ? { message: `${made.value.account} on ${made.value.host} is trusted until ${made.value.until.slice(0, 10)}.` }
122 : { tone: "danger", message: WORDS[made.error] },
123 );
124 }
125
126 // POST /settings/trusted/:id/remove
127 export async function trustedRemove(ctx: Ctx): Promise<Response> {
128 const denied = owner(ctx);
129 if (denied) return denied;
130 const form = await readForm(ctx, "admin");
131 if (form instanceof Response) return form;
132 const gone = removeTrust(ctx.app, ctx.auth.user, ctx.params.id ?? "");
133 return flashTo(
134 ctx,
135 "/settings/trusted",
136 gone.ok
137 ? { message: `${gone.value.account} on ${gone.value.host} now waits for review.` }
138 : { tone: "danger", message: WORDS.not_found },
139 );
140 }
141
142 // POST /settings/trusted/stop
143 export async function trustedStop(ctx: Ctx): Promise<Response> {
144 const denied = owner(ctx);
145 if (denied) return denied;
146 const form = await readForm(ctx, "admin");
147 if (form instanceof Response) return form;
148 const stop = field(form, "stop") === "1";
149 setFoldInStopped(ctx.app, ctx.auth.user, stop);
150 return flashTo(ctx, "/settings/trusted", { message: stop ? "Folding in is stopped." : "Folding in resumed." });
151 }
152
153 // POST /settings/trusted/revert
154 export async function trustedRevert(ctx: Ctx): Promise<Response> {
155 const denied = owner(ctx);
156 if (denied) return denied;
157 const form = await readForm(ctx, "admin");
158 if (form instanceof Response) return form;
159 if (field(form, "confirm") !== "1")
160 return flashTo(ctx, "/settings/trusted", { tone: "danger", message: "Tick “Open reverts” to confirm." });
161 const done = await revertBySource(ctx.app, ctx.auth.user, field(form, "host"), field(form, "account"));
162 return flashTo(
163 ctx,
164 "/settings/trusted",
165 done.ok
166 ? { message: `${done.value.opened} revert pull${done.value.opened === 1 ? "" : "s"} opened for you to land.` }
167 : { tone: "danger", message: WORDS.not_found },
168 );
169 }
src/web/studio/review_act.ts +3 −3
@@ -44,8 +44,8 @@ export function verbsFor(a: Ask): readonly Verb[] {
4444 return a.pull !== undefined ? VERBS : a.issue !== undefined ? ["close", "reply"] : [];
4545 }
4646
47 /** The buttons and boxes under a row, a sibling of its link; `undefined` when it offers none. */
48 export function actRow(a: Ask): Html | undefined {
47 /** The buttons and boxes under a row (a guest's pull from another forge says Approve and Decline), a sibling of its link; `undefined` when it offers none. */
48 export function actRow(a: Ask, guest = false): Html | undefined {
4949 const verbs = verbsFor(a);
5050 const number = a.pull?.number ?? a.issue?.number;
5151 if (verbs.length === 0 || number === undefined) return undefined;
@@ -66,7 +66,7 @@ export function actRow(a: Ask): Html | undefined {
6666 const quick = verbs.filter((v) => v === "merge" || v === "close");
6767 return html`<li data-act-forms>
6868 <div class="form-actions">${quick.map((v) =>
69 form(v, btn(v === "merge" ? "Merge" : "Close")),
69 form(v, btn(guest ? (v === "merge" ? "Approve" : "Decline") : v === "merge" ? "Merge" : "Close")),
7070 )}</div>
7171 ${verbs.includes("changes") ? box("changes", "Request changes: what must change", "Request changes") : null}
7272 ${box("reply", "Reply", "Reply")}
src/web/studio/review_page.ts +4 −1
@@ -20,6 +20,7 @@ import { badge, empty, listCard, row, runStateBadge, time } from "../ui.ts";
2020 import { lookThumb, lookThumbPlain, thumbLook } from "../pages/looks.ts";
2121 import { stepOfStored } from "../../flow/path.ts";
2222 import { stepLink } from "./step_link.ts";
23 import { isTrusted, pullSourceOf } from "../../core/trusted_sources.ts";
2324 import { actRow, rowId } from "./review_act.ts";
2425 import { reviewSet } from "./review_set.ts";
2526 import { staleArchive } from "./review_stale.ts";
@@ -121,7 +122,9 @@ export function reviewPage(ctx: Ctx): Response {
121122 : stat === "failed"
122123 ? " · Diff size unavailable — open the pull to see it"
123124 : "";
124 const act = actRow(a);
125 const from = a.pull === undefined ? undefined : pullSourceOf(ctx.app.db, a.pull.id);
126 // A guest's pull from another forge says Approve and Decline; a trusted source's (here for a wall) is a person's plain Merge.
127 const act = actRow(a, from !== undefined && !isTrusted(ctx.app.db, from.host, from.account));
125128 const step = a.pull === undefined ? null : stepOfStored(ctx.app.db, a.repo, a.pull);
126129 return row({
127130 href,
test/palette-roster.test.ts +1
@@ -72,6 +72,7 @@ test("a site-admin person is offered the whole roster", () => {
7272 ["decider", "Decider", "what it closed, deferred and asked you about today"],
7373 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
7474 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
75 ["trusted", "Trusted sources", "who folds in without review; a trusts, x removes, s stops all, v reverts"],
7576 ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"],
7677 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
7778 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
test/trusted-fold.test.ts added +291
@@ -0,0 +1,291 @@
1 /**
2 * Folding in, end to end (#8937): the autonomy tick's own sweep admits a
3 * trusted source's pull and lands it by true merge, offline, with the check
4 * and the boot proof still standing and the walls still sending a pull back
5 * to a person; a ring of forges stops itself; and the doors refuse a stranger.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { addTrust, setFoldInStopped } from "../src/actions/trusted_sources.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { getPull, type Pull } from "../src/core/pulls.ts";
18 import { getRepoById, type Repo } from "../src/core/repos.ts";
19 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
20 import { ownHost, recordPullSource, takeable, viaOf } from "../src/core/trusted_sources.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { landingStrategy, sweepGreen } from "../src/crew/autonomy.ts";
23 import { runGitCapture } from "../src/git/env.ts";
24 import { repoDir } from "../src/git/repos.ts";
25 import { serve } from "../src/http/server.ts";
26 import { logLevel, setLogLevel } from "../src/kernel/log.ts";
27
28 interface Forge {
29 readonly app: App;
30 readonly alice: User;
31 readonly repo: Repo;
32 readonly gated: string[];
33 readonly dir: string;
34 }
35
36 const FROM = "git.open-platform.sh";
37 const dirs: string[] = [];
38 const apps: App[] = [];
39 let level: ReturnType<typeof logLevel>;
40
41 async function forge(host: string): Promise<Forge> {
42 const dir = await mkdtemp(join(tmpdir(), "forge-fold-"));
43 dirs.push(dir);
44 const app = createApp({ dataDir: dir, port: 0, host });
45 apps.push(app);
46 const a = await createUser(app.db, {
47 origin: "seed",
48 trusted: true,
49 username: "alice",
50 email: "[email protected]",
51 password: "a-good-passphrase",
52 isAdmin: true,
53 });
54 if (!a.ok) throw new Error(a.error);
55 const gated: string[] = [];
56 provideChecksForTest(app, {
57 gate: async (_repo: Repo, _pull: Pull, commitOid: string) => {
58 gated.push(commitOid);
59 return { ok: true, error: "", lines: [] };
60 },
61 spareSlots: () => 0,
62 } as unknown as ChecksHandle);
63 const made = await createRepo(app, a.value, { name: "lib", visibility: "private" });
64 if (!made.ok) throw new Error(String(made.error));
65 const seeded = await commitToBranch(app, a.value, made.value, {
66 message: "Start",
67 files: [
68 { path: "README.md", content: "# lib\n" },
69 { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true" } }) },
70 ],
71 });
72 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
73 app.db.query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1").run(made.value.id, a.value.id);
74 const trusted = addTrust(app, a.value, { host: FROM, account: "jmoran", until: "2099-12-02" });
75 if (!trusted.ok) throw new Error(trusted.error);
76 return { app, alice: a.value, repo: getRepoById(app.db, made.value.id)!, gated, dir };
77 }
78
79 /** A pull from `source` (the follow's stamp), written by an outsider so only trust can land it; its check `state`, if any. */
80 async function pullFrom(
81 f: Forge,
82 branch: string,
83 file: string,
84 source: { host: string; account: string; via?: string[] },
85 check: "ok" | "failed" | "none" = "ok",
86 ): Promise<Pull> {
87 const pushed = await commitToBranch(f.app, f.alice, f.repo, {
88 branch,
89 base: "main",
90 message: `Add ${file}`,
91 files: [{ path: file, content: "export const n = 1;\n" }],
92 });
93 if (!pushed.ok) throw new Error(JSON.stringify(pushed.error));
94 const opened = await openPull(f.app, f.alice, f.repo, { title: branch, baseBranch: "main", headBranch: branch });
95 if (!opened.ok) throw new Error(String(opened.error));
96 f.app.db.query("UPDATE issue SET origin_author = 'outsider' WHERE id = ?1").run(opened.value.id);
97 recordPullSource(f.app.db, { pullId: opened.value.id, host: source.host, account: source.account, via: source.via ?? [] });
98 const pull = getPull(f.app.db, f.repo.id, opened.value.number)!;
99 if (check !== "none") {
100 const run = createRun(f.app.db, {
101 repoId: f.repo.id,
102 kind: "check",
103 issueId: pull.id,
104 env: `pull-${pull.number}`,
105 commitOid: pull.headOid,
106 reason: "pull",
107 });
108 claimRun(f.app.db, run.id);
109 finishRun(f.app.db, run.id, { state: check, summary: check });
110 }
111 return pull;
112 }
113
114 const merged = (f: Forge, pull: Pull): boolean => getPull(f.app.db, f.repo.id, pull.number)!.mergedAt !== null;
115
116 async function mergeMessage(f: Forge): Promise<string> {
117 const dir = repoDir(f.app.reposRoot, f.repo.ownerName, f.repo.name);
118 return (await runGitCapture(["log", "-1", "--format=%B", "refs/heads/main"], { cwd: dir })).stdout;
119 }
120
121 beforeAll(() => {
122 level = logLevel();
123 setLogLevel("error");
124 });
125
126 afterAll(async () => {
127 setLogLevel(level);
128 for (const app of apps) await app.close?.();
129 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
130 });
131
132 describe("the tick's sweep admits a trusted source's pull", () => {
133 let f: Forge;
134 beforeAll(async () => {
135 f = await forge("forge.test");
136 });
137
138 test("it lands by true merge, offline, with the check and the boot proof run and a credit trailer", async () => {
139 const pull = await pullFrom(f, "t/fine", "src/fine.ts", { host: FROM, account: "jmoran" });
140 const realFetch = globalThis.fetch;
141 globalThis.fetch = (() => {
142 throw new Error("folding reached the network");
143 }) as unknown as typeof fetch;
144 try {
145 expect(await sweepGreen(f.app, f.repo)).toBe(1);
146 } finally {
147 globalThis.fetch = realFetch;
148 }
149 expect(merged(f, pull)).toBe(true);
150 expect(f.gated.length).toBe(1);
151 const message = await mergeMessage(f);
152 expect(message).toContain(`Landed-from: jmoran@${FROM}`);
153 expect(viaOf(message)).toEqual([FROM, "forge.test"]);
154 const dir = repoDir(f.app.reposRoot, f.repo.ownerName, f.repo.name);
155 const parents = (await runGitCapture(["log", "-1", "--format=%P", "refs/heads/main"], { cwd: dir })).stdout.trim().split(" ");
156 expect(parents.length).toBe(2);
157 });
158
159 test("a pull with no check yet, or a red one, goes back", async () => {
160 const none = await pullFrom(f, "t/nocheck", "src/nocheck.ts", { host: FROM, account: "jmoran" }, "none");
161 const red = await pullFrom(f, "t/red", "src/red.ts", { host: FROM, account: "jmoran" }, "failed");
162 expect(await sweepGreen(f.app, f.repo)).toBe(0);
163 expect(merged(f, none)).toBe(false);
164 expect(merged(f, red)).toBe(false);
165 });
166
167 test("a pull touching the walls goes back to a person, green or not, with the reason on the ledger", async () => {
168 const pull = await pullFrom(f, "t/wall", "src/crew/fold_walls.ts", { host: FROM, account: "jmoran" });
169 expect(await sweepGreen(f.app, f.repo)).toBe(0);
170 expect(merged(f, pull)).toBe(false);
171 const row = f.app.db
172 .query<{ answer: string; state: string }, [string]>("SELECT answer, state FROM decision WHERE kind = 'land' AND issue_id = ?1")
173 .get(pull.id);
174 expect(row?.answer).toBe("wall");
175 expect(row?.state).toContain("permissions or the contract's walls");
176 });
177
178 test("a pull at a path nobody listed goes back too", async () => {
179 const pull = await pullFrom(f, "t/root", "Makefile", { host: FROM, account: "jmoran" });
180 expect(await sweepGreen(f.app, f.repo)).toBe(0);
181 expect(merged(f, pull)).toBe(false);
182 });
183
184 test("a stranger's, the same name on another forge, waits for review", async () => {
185 const pull = await pullFrom(f, "t/stranger", "src/stranger.ts", { host: "evil.example", account: "jmoran" });
186 expect(await sweepGreen(f.app, f.repo)).toBe(0);
187 expect(merged(f, pull)).toBe(false);
188 });
189
190 test("the kill switch holds a green trusted pull until folding resumes", async () => {
191 const pull = await pullFrom(f, "t/stop", "src/stop.ts", { host: FROM, account: "jmoran" });
192 setFoldInStopped(f.app, f.alice, true);
193 expect(await sweepGreen(f.app, f.repo)).toBe(0);
194 expect(merged(f, pull)).toBe(false);
195 setFoldInStopped(f.app, f.alice, false);
196 expect(await sweepGreen(f.app, f.repo)).toBe(1);
197 expect(merged(f, pull)).toBe(true);
198 });
199
200 test("a lapsed trust is not honoured at the tick's own time", async () => {
201 const pull = await pullFrom(f, "t/lapsed", "src/lapsed.ts", { host: FROM, account: "jmoran" });
202 expect(await sweepGreen(f.app, f.repo, new Date("2100-01-01T00:00:00Z"))).toBe(0);
203 expect(merged(f, pull)).toBe(false);
204 });
205 });
206
207 describe("a ring of three forges", () => {
208 test("each lands the work once and the third's hand-back to the first is refused", async () => {
209 const ring = [await forge("a.test"), await forge("b.test"), await forge("c.test")];
210 let via: string[] = [];
211 let from = FROM;
212 const taken: string[] = [];
213 for (let hop = 0; hop < 6; hop++) {
214 const f = ring[hop % 3]!;
215 const self = ownHost(f.app.config);
216 // What the follow checks before it opens anything.
217 if (!takeable(via, self)) break;
218 addTrust(f.app, f.alice, { host: from, account: "jmoran", until: "2099-12-02" });
219 const pull = await pullFrom(f, `ring/${hop}`, `src/ring${hop}.ts`, { host: from, account: "jmoran", via });
220 expect(await sweepGreen(f.app, f.repo)).toBe(1);
221 expect(merged(f, pull)).toBe(true);
222 via = viaOf(await mergeMessage(f));
223 from = self;
224 taken.push(self);
225 }
226 expect(taken).toEqual(["a.test", "b.test", "c.test"]);
227 expect(via).toEqual([FROM, "a.test", "b.test", "c.test"]);
228 });
229 });
230
231 describe("the doors", () => {
232 test("a non-admin is refused every change; the review page labels a guest's pull Approve and a walled trusted one Merge", async () => {
233 const f = await forge("doors.test");
234 const bob = await createUser(f.app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
235 if (!bob.ok) throw new Error(bob.error);
236 const servers = await serve(f.app);
237 try {
238 const as = (u: User) => ({ cookie: `forge_session=${createSession(f.app.db, u.id).token}`, origin: servers.url });
239 const post = (path: string, u: User, body: string) =>
240 fetch(`${servers.url}${path}`, {
241 method: "POST",
242 redirect: "manual",
243 headers: { ...as(u), "content-type": "application/x-www-form-urlencoded" },
244 body,
245 });
246 for (const [path, body] of [
247 ["/settings/trusted", "account=zed&host=x.test&until=2099-01-01"],
248 ["/settings/trusted/stop", "stop=1"],
249 ["/settings/trusted/revert", `host=${FROM}&account=jmoran&confirm=1`],
250 ["/settings/trusted/anything/remove", ""],
251 ] as const) {
252 const res = await post(path, bob.value, body);
253 await res.arrayBuffer();
254 expect(res.status).toBe(404);
255 }
256 const { listTrusted, foldInStopped } = await import("../src/core/trusted_sources.ts");
257 expect(listTrusted(f.app.db).map((s) => s.account)).toEqual(["jmoran"]);
258 expect(foldInStopped(f.app.db)).toBeUndefined();
259 // The revert asks first: no tick, no reverts.
260 const unasked = await post("/settings/trusted/revert", f.alice, `host=${FROM}&account=jmoran`);
261 await unasked.arrayBuffer();
262 expect(f.app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'trust.reverted'").get()).toEqual({ n: 0 });
263
264 // Assisted, so a green pull waits on /review: a trusted source's that hit a wall is a person's plain Merge …
265 f.app.db.query("UPDATE repo SET autonomy = 'assisted' WHERE id = ?1").run(f.repo.id);
266 await pullFrom(f, "g/walled", "src/crew/autonomy.ts", { host: FROM, account: "jmoran" });
267 const walled = await (await fetch(`${servers.url}/review`, { headers: as(f.alice) })).text();
268 expect(walled).toContain("g/walled");
269 expect(walled).toContain(">\n Merge");
270 expect(walled).not.toContain("Approve");
271 // … and a guest's, from a source nobody trusts, says Approve and Decline.
272 await pullFrom(f, "g/guest", "src/guest.ts", { host: "evil.example", account: "zed" });
273 const both = await (await fetch(`${servers.url}/review`, { headers: as(f.alice) })).text();
274 expect(both).toContain("Approve");
275 expect(both).toContain("Decline");
276 } finally {
277 servers.stop();
278 }
279 });
280 });
281
282 describe("the landing reads the clock it is given", () => {
283 test("a trusted source's pull merges while the trust holds, and squashes once it has lapsed", async () => {
284 const f = await forge("clock.test");
285 const pull = await pullFrom(f, "g/clock", "src/clock.ts", { host: FROM, account: "jmoran" });
286 const dir = repoDir(f.app.reposRoot, f.repo.ownerName, f.repo.name);
287 const base = (await runGitCapture(["rev-parse", "refs/heads/main"], { cwd: dir })).stdout.trim();
288 expect(await landingStrategy(f.app, f.repo, pull, base, new Date("2099-12-01T00:00:00Z"))).toBe("merge");
289 expect(await landingStrategy(f.app, f.repo, pull, base, new Date("2099-12-03T00:00:00Z"))).toBe("squash");
290 });
291 });
test/trusted-sources.test.ts added +242
@@ -0,0 +1,242 @@
1 /** Trusted sources fold in by themselves (#8937): trust skips the person, nothing else. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openPull } from "../src/actions/pulls.ts";
7 import { addTrust, removeTrust, setFoldInStopped } from "../src/actions/trusted_sources.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { pullMayRun } from "../src/core/pull_trust.ts";
11 import { getPull } from "../src/core/pulls.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import {
14 dueWarning,
15 foldsIn,
16 isTrusted,
17 listTrusted,
18 pullSourceOf,
19 recordPullSource,
20 sourceOfUrl,
21 sourceTrailers,
22 takeable,
23 trustSource,
24 viaOf,
25 } from "../src/core/trusted_sources.ts";
26 import type { User } from "../src/core/users.ts";
27 import { landsOnItsOwn } from "../src/crew/direct.ts";
28 import { warnExpiring } from "../src/crew/fold_in.ts";
29 import { wallsTouched } from "../src/crew/fold_walls.ts";
30 import { quietLogs } from "./helpers/globals.ts";
31 import { seedAdmin } from "./helpers/admin.ts";
32
33 quietLogs();
34
35 let dataDir = "";
36 let app: App;
37 let alice: User;
38 let repo: Repo;
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-trusted-"));
42 app = createApp({ dataDir, port: 0, host: "forge.test" });
43 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
44 if (!made.ok) throw new Error(made.error);
45 alice = made.value;
46 const r = await createRepo(app, alice, { name: "lib", visibility: "public" });
47 if (!r.ok) throw new Error(String(r.error));
48 repo = r.value;
49 const first = await commitToBranch(app, alice, repo, { message: "First", files: [{ path: "README.md", content: "hi\n" }] });
50 if (!first.ok) throw new Error(JSON.stringify(first.error));
51 // The owner's list is theirs to fill: nothing is trusted until an owner action says so.
52 expect(listTrusted(app.db)).toEqual([]);
53 for (const who of ["tdobbertin-espo", "jmoran", "benjivers"])
54 if (!addTrust(app, alice, { host: "git.open-platform.sh", account: who, until: "2099-12-02" }).ok) throw new Error(who);
55 });
56
57 afterAll(async () => {
58 app.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 const HOST = "git.open-platform.sh";
63
64 describe("the list", () => {
65 test("nothing is trusted until an owner says so, then the name on that forge only", () => {
66 const now = new Date("2026-11-03T00:00:00Z");
67 for (const who of ["tdobbertin-espo", "jmoran", "benjivers"]) {
68 expect(isTrusted(app.db, HOST, who, now)).toBe(true);
69 expect(isTrusted(app.db, "evil.example", who, now)).toBe(false);
70 }
71 expect(listTrusted(app.db).every((s) => s.until.startsWith("2099-12-02"))).toBe(true);
72 });
73
74 test("expiry falls to review on its own, with a note 3 days before, once", () => {
75 expect(isTrusted(app.db, HOST, "jmoran", new Date("2099-12-03T00:00:00Z"))).toBe(false);
76 expect(dueWarning(app.db, new Date("2099-11-20T00:00:00Z"))).toEqual([]);
77 expect(dueWarning(app.db, new Date("2099-11-30T00:00:00Z")).length).toBe(3);
78 expect(warnExpiring(app, new Date("2099-11-30T00:00:00Z"))).toBe(3);
79 expect(warnExpiring(app, new Date("2099-11-30T01:00:00Z"))).toBe(0);
80 expect(app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'trust.expiring'").get()).toEqual({ n: 3 });
81 });
82
83 test("a change is audited, only an admin person may make it, and input is validated", () => {
84 const bob = { ...alice, id: "x", isAdmin: false };
85 expect(addTrust(app, bob, { host: HOST, account: "zed", until: "2027-01-01" }).ok).toBe(false);
86 expect(addTrust(app, alice, { host: "no spaces", account: "zed", until: "2027-01-01" })).toEqual({ ok: false, error: "bad_host" });
87 expect(addTrust(app, alice, { host: HOST, account: "../x", until: "2027-01-01" })).toEqual({ ok: false, error: "bad_account" });
88 expect(addTrust(app, alice, { host: HOST, account: "zed", until: "soon" })).toEqual({ ok: false, error: "bad_date" });
89 expect(addTrust(app, alice, { host: HOST, account: "zed", until: "2020-01-01" })).toEqual({ ok: false, error: "in_the_past" });
90 const added = addTrust(app, alice, { host: `https://${HOST}/`, account: "@Zed", until: "2027-01-01" });
91 if (!added.ok) throw new Error(added.error);
92 expect(added.value).toMatchObject({ host: HOST, account: "zed", setBy: "alice" });
93 const removed = removeTrust(app, alice, added.value.id);
94 expect(removed.ok).toBe(true);
95 expect(isTrusted(app.db, HOST, "zed")).toBe(false);
96 const kinds = app.db.query<{ kind: string }, []>("SELECT kind FROM event WHERE kind LIKE 'trust.%'").all().map((e) => e.kind);
97 expect(kinds).toContain("trust.set");
98 expect(kinds).toContain("trust.removed");
99 });
100 });
101
102 describe("what trust skips, and what it never does", () => {
103 const push = async (branch: string, path: string) => {
104 const done = await commitToBranch(app, alice, repo, {
105 branch,
106 message: path,
107 files: [{ path, content: "x\n" }],
108 });
109 if (!done.ok) throw new Error(JSON.stringify(done.error));
110 return done.value.commitOid;
111 };
112 const pullFrom = async (branch: string, account: string, host = HOST) => {
113 await push(branch, `${branch}.md`);
114 const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch });
115 if (!opened.ok) throw new Error(String(opened.error));
116 recordPullSource(app.db, { pullId: opened.value.id, host, account, via: [] });
117 return getPull(app.db, repo.id, opened.value.number)!;
118 };
119
120 test("a trusted source's pull lands without a person, a guest's does not", async () => {
121 const trusted = await pullFrom("t/one", "jmoran");
122 const guest = await pullFrom("t/two", "jmoran", "other.example");
123 expect(foldsIn(app.db, trusted.id)).toBe(true);
124 expect(landsOnItsOwn(app.db, repo, trusted)).toBe(true);
125 expect(foldsIn(app.db, guest.id)).toBe(false);
126 expect(pullMayRun(app.db, repo, trusted, trusted.headOid)).toBe(true);
127 });
128
129 test("the kill switch stops all folding in, and resuming restores it", async () => {
130 const pull = await pullFrom("t/three", "benjivers");
131 expect(foldsIn(app.db, pull.id)).toBe(true);
132 expect(setFoldInStopped(app, alice, true).ok).toBe(true);
133 expect(foldsIn(app.db, pull.id)).toBe(false);
134 setFoldInStopped(app, alice, false);
135 expect(foldsIn(app.db, pull.id)).toBe(true);
136 });
137
138 test("an expired source's pull waits for review", async () => {
139 const pull = await pullFrom("t/four", "tdobbertin-espo");
140 expect(foldsIn(app.db, pull.id, new Date("2100-01-01T00:00:00Z"))).toBe(false);
141 });
142
143 const walls: [string, string][] = [
144 [".env", "secrets or credentials"],
145 ["config/secrets.json", "secrets or credentials"],
146 ["deploy/server.pem", "secrets or credentials"],
147 ["src/core/sessions.ts", "secrets or credentials"],
148 ["src/core/roles.ts", "permissions or the contract's walls"],
149 ["src/contract/walls.ts", "permissions or the contract's walls"],
150 ["src/core/pull_trust.ts", "permissions or the contract's walls"],
151 ["src/crew/fold_walls.ts", "permissions or the contract's walls"],
152 ["src/crew/autonomy.ts", "permissions or the contract's walls"],
153 ["src/core/event_audience.ts", "permissions or the contract's walls"],
154 ["src/actions/trusted_sources.ts", "permissions or the contract's walls"],
155 ["src/http/passport_auth.ts", "permissions or the contract's walls"],
156 ["src/core/migrations/20270101000000-x.ts", "release machinery"],
157 ["src/core/migration-pins/20270101000000-x.pin", "release machinery"],
158 ["scripts/cut-release.ts", "release machinery"],
159 ["forge.json", "release machinery"],
160 ["Dockerfile", "release machinery"],
161 ];
162 for (const [file, why] of walls)
163 test(`a pull touching ${file} still waits for a person`, () => {
164 expect(wallsTouched([file])).toEqual([`a trusted source's pull touches ${why}`]);
165 });
166
167 test("ordinary files are no wall", () => {
168 expect(wallsTouched(["src/web/pages/home.ts", "README.md", "test/a.test.ts", "docs/a.md"])).toEqual([]);
169 });
170
171 test("a path nobody listed waits for a person: an allowlist, not a denylist", () => {
172 expect(wallsTouched(["scripts/new-thing.ts"]).length).toBe(1);
173 expect(wallsTouched(["AGENTS.md"]).length).toBe(1);
174 expect(wallsTouched(["somewhere/else.txt"]).length).toBe(1);
175 });
176
177 test("a pull's source is stamped once: a later claim never rewrites it", async () => {
178 const pull = await pullFrom("t/stamp", "jmoran");
179 recordPullSource(app.db, { pullId: pull.id, host: "evil.example", account: "mallory", via: [] });
180 expect(pullSourceOf(app.db, pull.id)).toMatchObject({ host: HOST, account: "jmoran" });
181 });
182 });
183
184 describe("a ring of forges", () => {
185 test("work that passed through a forge is not taken by it again", () => {
186 const names = ["a.test", "b.test", "c.test"];
187 // A lands work by someone on x.test; B follows A, C follows B, A follows C.
188 let via: string[] = [];
189 let origin = "x.test";
190 const trail: string[] = [];
191 for (let hop = 0; hop < 6; hop++) {
192 const self = names[hop % 3]!;
193 if (!takeable(via, self)) break;
194 const trailers = sourceTrailers({ pullId: "p", host: origin, account: "jmoran", via }, self);
195 via = viaOf(`Merge\n\n${trailers}`);
196 origin = self;
197 trail.push(self);
198 }
199 expect(trail).toEqual(["a.test", "b.test", "c.test"]);
200 expect(via).toEqual(["x.test", "a.test", "b.test", "c.test"]);
201 });
202
203 test("a landing says where it came from", () => {
204 const text = sourceTrailers({ pullId: "p", host: HOST, account: "jmoran", via: [] }, "forge.test");
205 expect(text).toContain(`Landed-from: jmoran@${HOST}`);
206 expect(viaOf(text)).toEqual([HOST, "forge.test"]);
207 });
208
209 test("an upstream's address names its forge and account", () => {
210 expect(sourceOfUrl(`https://${HOST}/jmoran/app.git`)).toEqual({ host: HOST, account: "jmoran" });
211 expect(sourceOfUrl("ssh://x/y")).toBeUndefined();
212 });
213
214 test("a pull's source is read back", () => {
215 recordPullSource(app.db, { pullId: "zz", host: HOST, account: "jmoran", via: ["a.test"] });
216 expect(pullSourceOf(app.db, "zz")?.via).toEqual(["a.test"]);
217 expect(trustSource(app.db, { host: HOST, account: "q", until: "2020-01-01", setBy: "t" })).toBe("in_the_past");
218 });
219 });
220
221 describe("the settings page", () => {
222 test("lists the entries with a one-click remove and refuses a stranger", async () => {
223 const { serve } = await import("../src/http/server.ts");
224 const { createSession } = await import("../src/core/sessions.ts");
225 const servers = await serve(app);
226 try {
227 const anon = await fetch(`${servers.url}/settings/trusted`, { redirect: "manual" });
228 await anon.arrayBuffer();
229 expect(anon.status).not.toBe(200);
230 const res = await fetch(`${servers.url}/settings/trusted`, {
231 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
232 });
233 const text = await res.text();
234 expect(res.status).toBe(200);
235 expect(text).toContain("tdobbertin-espo");
236 expect(text).toContain("/remove");
237 expect(text).toContain("Stop all folding in");
238 } finally {
239 servers.stop();
240 }
241 });
242 });
test/tui-trusted.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Trusted sources in the terminal (#8937): a forge admin who is a person is
3 * offered the screen, `a` trusts, `x` removes, `s` stops and resumes folding
4 * in, `v` opens reverts — each through `actions/trusted_sources.ts`, so each
5 * leaves the web's event. Anyone else is not offered it, and its keys do
6 * nothing when it is pushed anyway.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { foldInStopped, listTrusted, trustSource } from "../src/core/trusted_sources.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { trustedScreen } from "../src/tui/screens/trusted.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 const ENTER = "\r";
24 const RIGHT = "\x1b[C";
25
26 let dir: string;
27 let app: App;
28 let ada: User; // the forge admin
29 let bob: User; // signed in, not an admin
30
31 const identity = (user: User) => ({ sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
32
33 const events = (kind: string): number =>
34 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM event WHERE kind = ?1").get(kind)!.n;
35
36 /** Opens the screen as `user`, feeds each key in turn, and returns what is drawn. */
37 async function drive(user: User, keys: readonly string[] = []) {
38 const terminal = headlessTerminal(120, 30);
39 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
40 tui.start();
41 const offered = tui.commands.some((c) => c.id === "trusted");
42 tui.ctx.push(trustedScreen(session, tui.ctx));
43 await settleApp(tui);
44 for (const key of keys) {
45 terminal.feed(key);
46 await settleApp(tui);
47 }
48 const text = tui.screen.toText();
49 tui.stop?.();
50 return { offered, text };
51 }
52
53 beforeAll(async () => {
54 dir = await mkdtemp(join(tmpdir(), "forge-tui-trusted-"));
55 app = createApp({ dataDir: dir, port: 0 });
56 const mk = async (username: string, first = false) => {
57 const r = await createUser(app.db, {
58 origin: "seed",
59 trusted: true,
60 username,
61 email: `${username}@forge.local`,
62 password: "a-good-passphrase",
63 adminIfFirst: first,
64 });
65 if (!r.ok) throw new Error(r.error);
66 return r.value;
67 };
68 ada = await mk("ada", true);
69 bob = await mk("bob");
70 }, 30_000);
71
72 afterAll(async () => {
73 app.close();
74 await rm(dir, { recursive: true, force: true });
75 });
76
77 describe("trusted sources in the terminal", () => {
78 test("a non-admin is not offered the screen, and its keys change nothing", async () => {
79 const made = trustSource(app.db, { host: "elsewhere.test", account: "zed", until: "2099-01-01", setBy: "ada" });
80 if (typeof made === "string") throw new Error(made);
81 const { offered, text } = await drive(bob, ["a", "zoe elsewhere.test 2099-01-01", ENTER, "x", RIGHT, ENTER, "s", RIGHT, ENTER, "v", RIGHT, ENTER]);
82 expect(offered).toBe(false);
83 expect(text).toContain("Only a forge admin changes who is trusted.");
84 expect(text).not.toContain("zed");
85 expect(listTrusted(app.db).map((s) => s.account)).toEqual(["zed"]);
86 expect(foldInStopped(app.db)).toBeUndefined();
87 for (const kind of ["trust.set", "trust.removed", "foldin.stopped", "trust.reverted"]) expect(events(kind)).toBe(0);
88 app.db.query("DELETE FROM trusted_source").run();
89 });
90
91 test("an admin is offered it; a trusts a source until a date", async () => {
92 const { offered, text } = await drive(ada, ["a", "jmoran git.open-platform.sh 2099-12-02", ENTER]);
93 expect(offered).toBe(true);
94 expect(text).toContain("jmoran git.open-platform.sh");
95 expect(text).toContain("until 2099-12-02");
96 expect(listTrusted(app.db).map((s) => [s.account, s.host])).toEqual([["jmoran", "git.open-platform.sh"]]);
97 expect(events("trust.set")).toBe(1);
98 });
99
100 test("a refuses a bad entry with the page's sentence and trusts nobody", async () => {
101 const { text } = await drive(ada, ["a", "jmoran git.open-platform.sh 2001-01-01", ENTER]);
102 expect(text).toContain("That date has passed.");
103 expect(listTrusted(app.db)).toHaveLength(1);
104 expect(events("trust.set")).toBe(1);
105 });
106
107 test("s stops all folding in after a confirm, and s again resumes it", async () => {
108 // Cancel is the default: Enter alone stops nothing.
109 await drive(ada, ["s", ENTER]);
110 expect(foldInStopped(app.db)).toBeUndefined();
111 const stopped = await drive(ada, ["s", RIGHT, ENTER]);
112 expect(foldInStopped(app.db)?.by).toBe("ada");
113 expect(stopped.text).toContain("Folding in is stopped (by ada)");
114 expect(events("foldin.stopped")).toBe(1);
115 await drive(ada, ["s", RIGHT, ENTER]);
116 expect(foldInStopped(app.db)).toBeUndefined();
117 expect(events("foldin.resumed")).toBe(1);
118 });
119
120 test("v opens reverts for the source under the cursor after a confirm", async () => {
121 await drive(ada, ["v", ENTER]);
122 expect(events("trust.reverted")).toBe(0);
123 await drive(ada, ["v", RIGHT, ENTER]);
124 const said = app.db
125 .query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'trust.reverted'")
126 .all()
127 .map((e) => JSON.parse(e.payload));
128 expect(said).toHaveLength(1);
129 expect(said[0]).toMatchObject({ account: "jmoran", host: "git.open-platform.sh", opened: 0 });
130 });
131
132 test("x removes the source under the cursor after a confirm", async () => {
133 await drive(ada, ["x", ENTER]);
134 expect(listTrusted(app.db)).toHaveLength(1);
135 const { text } = await drive(ada, ["x", RIGHT, ENTER]);
136 expect(listTrusted(app.db)).toHaveLength(0);
137 expect(text).not.toContain("jmoran git.open-platform.sh");
138 expect(events("trust.removed")).toBe(1);
139 });
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs