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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8937-trusted-sources-fold-in-by-themselves-in → main 9 commits fork 3 threads · 3 unresolved
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`, | ||
| 100 | 100 | - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen. |
| 101 | 101 | - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name. |
| 102 | 102 | - `/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. | |
| 103 | 104 | - `/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). |
| 104 | 105 | - `/settings/resilience` — `pages/domains.ts` · failure domains and their last drill · `/resilience` · none seen. |
| 105 | 106 | - `/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 { | ||
| 81 | 81 | import { canWrite } from "../core/repos.ts"; |
| 82 | 82 | import { refreshPullHead as refreshHead } from "../core/following.ts"; |
| 83 | 83 | import { carryBranchThrough } from "../origins/push-through.ts"; |
| 84 | import { ownHost, pullSourceOf, sourceTrailers } from "../core/trusted_sources.ts"; | |
| 84 | 85 | |
| 85 | 86 | export interface OpenPullInput { |
| 86 | 87 | title: string; |
| @@ -402,10 +403,14 @@ export async function mergePull( | ||
| 402 | 403 | const asked = toldBy(app.db, pull); |
| 403 | 404 | const requester = |
| 404 | 405 | 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"); | |
| 409 | 414 | const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 410 | 415 | const at = await pullDir(app.db, app.reposRoot, repo, pull); |
| 411 | 416 | const carriedBefore = |
| @@ -465,6 +470,7 @@ export async function mergePull( | ||
| 465 | 470 | number: pull.number, |
| 466 | 471 | strategy, |
| 467 | 472 | mergeOid: merged.value.mergeOid, |
| 473 | ...(from === undefined ? {} : { source: `${from.account}@${from.host}` }), | |
| 468 | 474 | }, |
| 469 | 475 | }); |
| 470 | 476 | // 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"; | ||
| 44 | 44 | import { aheadCount, mergeBase, mergeable, resolveOid, writeRef } from "../git/merge.ts"; |
| 45 | 45 | import { REMOTE_PROTOCOLS } from "../git/remote-env.ts"; |
| 46 | 46 | import { repoDir } from "../git/repos.ts"; |
| 47 | import { ownHost, recordPullSource, sourceOfUrl, takeable, viaOf } from "../core/trusted_sources.ts"; | |
| 47 | 48 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 48 | 49 | import { providerForOrigin } from "../origins/credential.ts"; |
| 49 | 50 | import { redact, secretsIn } from "../origins/credentials.ts"; |
| @@ -301,6 +302,12 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr | ||
| 301 | 302 | return ok({ tip: staged, behind, outcome: "none" }); |
| 302 | 303 | } |
| 303 | 304 | |
| 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 | } | |
| 304 | 311 | const merged = await mergeable(dir, main, staged); |
| 305 | 312 | if (!merged.ok) { |
| 306 | 313 | if (merged.error.kind !== "conflict") return failed("git"); |
| @@ -323,6 +330,12 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr | ||
| 323 | 330 | const known = pullForBranch(app.db, repo.id, name); |
| 324 | 331 | const pending = follow.pendingId === null ? undefined : getIssueById(app.db, follow.pendingId); |
| 325 | 332 | 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 | }; | |
| 326 | 339 | let number: number; |
| 327 | 340 | if (known !== undefined) { |
| 328 | 341 | // 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 | ||
| 330 | 343 | } else if (open) { |
| 331 | 344 | // The upstream moved on: the open pull follows it instead of a second opening. |
| 332 | 345 | retargetPull(app.db, pending.id, name, staged); |
| 346 | came(pending.id); | |
| 333 | 347 | await writeRef(dir, `refs/pull/${pending.number}/head`, staged); |
| 334 | 348 | updateIssueText(app.db, pending.id, text); |
| 335 | 349 | number = pending.number; |
| 336 | 350 | } else { |
| 337 | 351 | const opened = await openPull(app, by, repo, { ...text, baseBranch: repo.defaultBranch, headBranch: name }); |
| 338 | 352 | if (!opened.ok) return failed("git", `The proposal could not be opened: ${opened.error}`); |
| 353 | came(opened.value.id); | |
| 339 | 354 | noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "", pendingId: opened.value.id, opened: true }); |
| 340 | 355 | return ok({ tip: staged, behind, outcome: "proposed", pull: opened.value.number }); |
| 341 | 356 | } |
src/core/event_audience.ts +3
| @@ -169,6 +169,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 169 | 169 | kind.startsWith("clog.") || |
| 170 | 170 | // What the throughput keeper did to lift a brake is the owner's too (`flow/keeper.ts`). |
| 171 | 171 | 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.") || | |
| 172 | 175 | // Events the retired FLOW ONLY switch wrote stay the admins' to hear. |
| 173 | 176 | kind === "flow_only.cleared" || |
| 174 | 177 | // 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"; | ||
| 22 | 22 | import { canWrite, getRepoById, onCrew, type Repo } from "./repos.ts"; |
| 23 | 23 | import { listReviews } from "./reviews.ts"; |
| 24 | 24 | import { runGrantedAt } from "./run_grant.ts"; |
| 25 | import { foldsIn } from "./trusted_sources.ts"; | |
| 25 | 26 | import { getUserById, type User } from "./users.ts"; |
| 26 | 27 | |
| 27 | 28 | const trusted = (db: Database, repo: Repo, user: User | undefined): boolean => |
| @@ -40,6 +41,8 @@ export function pullMayRun( | ||
| 40 | 41 | headOid: string, |
| 41 | 42 | ): boolean { |
| 42 | 43 | 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; | |
| 43 | 46 | if (!proposed && !pull.crossRepo && pull.originHeadRepo === null) return true; |
| 44 | 47 | if (!proposed && pull.crossRepo && pull.originHeadRepo === null) { |
| 45 | 48 | 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 { | ||
| 132 | 132 | landsOnItsOwn, |
| 133 | 133 | markReaders, |
| 134 | 134 | } 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"; | |
| 135 | 138 | import { crewOffInPreview } from "./preview.ts"; |
| 136 | 139 | import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts"; |
| 137 | 140 | import { recheckRedApproved } from "./red_approved.ts"; |
| @@ -1150,7 +1153,7 @@ function answeredWithoutChange( | ||
| 1150 | 1153 | * was turned on, or one the event missed — land on the tick, so nothing |
| 1151 | 1154 | * green waits on chance. |
| 1152 | 1155 | */ |
| 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> { | |
| 1154 | 1157 | if (repo.autonomy !== "autonomous") return 0; |
| 1155 | 1158 | // Admitted one by one, queued together, oldest first: the green pulls of |
| 1156 | 1159 | // one sweep are one candidate and one gate (#2036). |
| @@ -1158,7 +1161,7 @@ export async function sweepGreen(app: App, repo: Repo): Promise<number> { | ||
| 1158 | 1161 | // Why each approved pull waits, for the tick line (#3055). |
| 1159 | 1162 | const waits: Wait[] = []; |
| 1160 | 1163 | 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); | |
| 1162 | 1165 | if (admitted.ok) entries.push(admitted.value); |
| 1163 | 1166 | const seen = reviewSummary(app.db, pull.id, pull.headOid); |
| 1164 | 1167 | if (seen.approvals > 0 && seen.changesRequested === 0) |
| @@ -1281,6 +1284,12 @@ export async function autonomyTick( | ||
| 1281 | 1284 | } catch (error) { |
| 1282 | 1285 | log.error("stranded sweep failed", { error: String(error) }); |
| 1283 | 1286 | } |
| 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 | } | |
| 1284 | 1293 | for (const repo of listAutonomousRepos(app.db)) { |
| 1285 | 1294 | // An archived repository is put away: the crew starts nothing in it. |
| 1286 | 1295 | if (repo.archivedAt !== null) continue; |
| @@ -1919,10 +1928,14 @@ async function reviewed( | ||
| 1919 | 1928 | app: App, |
| 1920 | 1929 | repo: Repo, |
| 1921 | 1930 | pull: Pull, |
| 1931 | now: Date = new Date(), | |
| 1922 | 1932 | ): Promise<"pass" | "hold" | "lapsed"> { |
| 1923 | 1933 | const seen = reviewSummary(app.db, pull.id, pull.headOid); |
| 1924 | 1934 | if (seen.changesRequested > 0) return "hold"; |
| 1925 | 1935 | 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"; | |
| 1926 | 1939 | // An agent's pull ships only on an approving review — the crew's reviewer |
| 1927 | 1940 | // or a person's. With no reviewer, or a review that lapsed, it waits for |
| 1928 | 1941 | // a person (`waitsForPerson` puts it where a person looks); never by lapse. |
| @@ -1965,6 +1978,7 @@ async function admit( | ||
| 1965 | 1978 | app: App, |
| 1966 | 1979 | repo: Repo, |
| 1967 | 1980 | pull: Pull, |
| 1981 | now: Date = new Date(), | |
| 1968 | 1982 | ): Promise<Result<Queued, Wait>> { |
| 1969 | 1983 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return err("off"); |
| 1970 | 1984 | if (pull.state !== "open" || pull.mergedAt !== null) return err("closed"); |
| @@ -1980,7 +1994,7 @@ async function admit( | ||
| 1980 | 1994 | // Only work the crew wrote, or a writer's, lands on green by itself: a |
| 1981 | 1995 | // reader's pull — from a fork, say — waits for a person, outsiders |
| 1982 | 1996 | // 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"); | |
| 1984 | 1998 | const by = getUserById(app.db, repo.autonomyBy); |
| 1985 | 1999 | if (!by) return err("off"); |
| 1986 | 2000 | // The newest run of each kind is what the head says: a red that a re-run |
| @@ -2070,6 +2084,27 @@ async function admit( | ||
| 2070 | 2084 | } |
| 2071 | 2085 | return err("wall"); |
| 2072 | 2086 | } |
| 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 | } | |
| 2073 | 2108 | // One gate for every pull, whoever wrote it: when the crew has a |
| 2074 | 2109 | // reviewer, a green head is queued for review and waits for an approval |
| 2075 | 2110 | // on that very head (a person's counts the same). A push is a new head, |
| @@ -2077,7 +2112,7 @@ async function admit( | ||
| 2077 | 2112 | // long it takes — with no reviewer, or after one lapsed, a person's |
| 2078 | 2113 | // (#597). Only a person's own pull lands without a reviewer, or after a |
| 2079 | 2114 | // review lapsed. |
| 2080 | const review = await reviewed(app, repo, pull); | |
| 2115 | const review = await reviewed(app, repo, pull, now); | |
| 2081 | 2116 | if (review === "hold") return err("review"); |
| 2082 | 2117 | // The full tier, once, on the queue's candidate (#1198, #2036). Red is |
| 2083 | 2118 | // told to the pull once per head and base, and the pull is out of the |
| @@ -2214,7 +2249,11 @@ export async function landingStrategy( | ||
| 2214 | 2249 | repo: Repo, |
| 2215 | 2250 | pull: Pull, |
| 2216 | 2251 | baseOid: string, |
| 2252 | now: Date = new Date(), | |
| 2217 | 2253 | ): 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"; | |
| 2218 | 2257 | return (await carriesUpstream( |
| 2219 | 2258 | app, |
| 2220 | 2259 | repo, |
src/crew/direct.ts +5 −1
| @@ -19,6 +19,7 @@ import { crewRole } from "../core/agents.ts"; | ||
| 19 | 19 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; |
| 20 | 20 | import type { Issue } from "../core/issues.ts"; |
| 21 | 21 | import { isProposed } from "../core/pull_trust.ts"; |
| 22 | import { foldsIn } from "../core/trusted_sources.ts"; | |
| 22 | 23 | import { openPullsClosing, type Pull } from "../core/pulls.ts"; |
| 23 | 24 | import { getUserById, getUserByName, type User } from "../core/users.ts"; |
| 24 | 25 | |
| @@ -223,7 +224,10 @@ export function unadoptedAmong( | ||
| 223 | 224 | * outsiders switch says — the switch lets a reader's words reach the |
| 224 | 225 | * crew, not a reader's code reach the default branch unread by a person. |
| 225 | 226 | */ |
| 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; | |
| 227 | 231 | if (pull.originAuthor !== null) return false; |
| 228 | 232 | // A child's code, whoever holds its token: a grant lets it run, a person's |
| 229 | 233 | // 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"; | ||
| 15 | 15 | import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; |
| 16 | 16 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 17 | 17 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 18 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; | |
| 18 | 19 | import { usagePage } from "../web/pages/usage.ts"; |
| 19 | 20 | import { spendPage } from "../web/pages/spend-tab.ts"; |
| 20 | 21 | import { |
| @@ -112,6 +113,11 @@ import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts"; | ||
| 112 | 113 | import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts"; |
| 113 | 114 | |
| 114 | 115 | 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); | |
| 115 | 121 | router.add("GET", "/settings/telemetry", telemetryPage); |
| 116 | 122 | router.add("POST", "/settings/telemetry", telemetrySet); |
| 117 | 123 | router.add("GET", "/settings", accountPage); |
src/release/landings.ts +2
| @@ -28,6 +28,8 @@ export interface Landing { | ||
| 28 | 28 | readonly live: boolean; |
| 29 | 29 | /** The bet whose scopes this pull built (#8713), set by the page that shows it. */ |
| 30 | 30 | 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; | |
| 31 | 33 | } |
| 32 | 34 | |
| 33 | 35 | export interface Landings { |
src/tui/forge.ts +9
| @@ -54,6 +54,7 @@ import { talkChatsScreen } from "./screens/talk_chats.ts"; | ||
| 54 | 54 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 55 | 55 | import { passkeysScreen } from "./screens/passkeys.ts"; |
| 56 | 56 | import { confirmsScreen } from "./screens/confirms.ts"; |
| 57 | import { mayTrust, trustedScreen } from "./screens/trusted.ts"; | |
| 57 | 58 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 58 | 59 | import { signInsScreen } from "./screens/signins.ts"; |
| 59 | 60 | import { originAppsScreen } from "./screens/originapps.ts"; |
| @@ -317,6 +318,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 317 | 318 | hint: "what the forge's agent asked to do; y does it once", |
| 318 | 319 | run: () => ctx.push(confirmsScreen(session, ctx)), |
| 319 | 320 | }); |
| 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 | }); | |
| 320 | 329 | // The forge's agent keeps many conversations, as /talk lists them (#8814). |
| 321 | 330 | if (session.signedIn && session.user?.kind !== "agent") |
| 322 | 331 | commands.push({ |
src/tui/screens/resilience.ts +2 −1
| @@ -255,11 +255,12 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 255 | 255 | const out: string[] = []; |
| 256 | 256 | if (asking !== undefined) out.push(QUESTION[asking], ""); |
| 257 | 257 | 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), ""); | |
| 258 | 260 | out.push(...wrap(backupsLine(f.app).says, INNER), ...verdictLines(f), ""); |
| 259 | 261 | for (const line of nightlyLines(f.app.config.dataDir)) out.push(...wrap(line, INNER)); |
| 260 | 262 | out.push(""); |
| 261 | 263 | out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER)); |
| 262 | if (note !== "") out.push(...wrap(note, INNER)); | |
| 263 | 264 | if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.", "t tests where snapshots go."); |
| 264 | 265 | out.push("", "Snapshots, newest first:"); |
| 265 | 266 | 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 @@ | ||
| 9 | 9 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 11 | import { betNamesOfPulls } from "../../core/bets.ts"; |
| 12 | import { sourcesOfPulls } from "../../core/trusted_sources.ts"; | |
| 12 | 13 | import { canRead, getRepo, type Repo } from "../../core/repos.ts"; |
| 13 | 14 | import { newestLive, secondsWords } from "../../keeper/landed.ts"; |
| 14 | 15 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| @@ -30,7 +31,7 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm | ||
| 30 | 31 | return row({ |
| 31 | 32 | href: `/${l.repo}/pulls/${l.number}`, |
| 32 | 33 | 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}`}`, | |
| 34 | 35 | trail: time(l.at), |
| 35 | 36 | }); |
| 36 | 37 | } |
| @@ -184,8 +185,15 @@ export async function changelogJson(ctx: Ctx): Promise<Response> { | ||
| 184 | 185 | */ |
| 185 | 186 | function withBets(ctx: Ctx, read: Landings, repo: Repo | undefined): { read: Landings; bets: string[]; bet: string } { |
| 186 | 187 | 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 | }; | |
| 189 | 197 | const live = read.live.map(mark); |
| 190 | 198 | const onItsWay = read.onItsWay.map(mark); |
| 191 | 199 | 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 = | ||
| 93 | 93 | | "spend" |
| 94 | 94 | | "crew" |
| 95 | 95 | | "accounts" |
| 96 | | "trusted" | |
| 96 | 97 | | "people" |
| 97 | 98 | | "subscriptions" |
| 98 | 99 | | "backups" |
| @@ -137,6 +138,12 @@ export function accountSections( | ||
| 137 | 138 | icon: "bot" as const, |
| 138 | 139 | current: current === "crew", |
| 139 | 140 | }, |
| 141 | { | |
| 142 | label: "Trusted sources", | |
| 143 | href: "/settings/trusted", | |
| 144 | icon: "lock" as const, | |
| 145 | current: current === "trusted", | |
| 146 | }, | |
| 140 | 147 | { |
| 141 | 148 | label: "Subscriptions", |
| 142 | 149 | 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[] { | ||
| 44 | 44 | return a.pull !== undefined ? VERBS : a.issue !== undefined ? ["close", "reply"] : []; |
| 45 | 45 | } |
| 46 | 46 | |
| 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 { | |
| 49 | 49 | const verbs = verbsFor(a); |
| 50 | 50 | const number = a.pull?.number ?? a.issue?.number; |
| 51 | 51 | if (verbs.length === 0 || number === undefined) return undefined; |
| @@ -66,7 +66,7 @@ export function actRow(a: Ask): Html | undefined { | ||
| 66 | 66 | const quick = verbs.filter((v) => v === "merge" || v === "close"); |
| 67 | 67 | return html`<li data-act-forms> |
| 68 | 68 | <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")), | |
| 70 | 70 | )}</div> |
| 71 | 71 | ${verbs.includes("changes") ? box("changes", "Request changes: what must change", "Request changes") : null} |
| 72 | 72 | ${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"; | ||
| 20 | 20 | import { lookThumb, lookThumbPlain, thumbLook } from "../pages/looks.ts"; |
| 21 | 21 | import { stepOfStored } from "../../flow/path.ts"; |
| 22 | 22 | import { stepLink } from "./step_link.ts"; |
| 23 | import { isTrusted, pullSourceOf } from "../../core/trusted_sources.ts"; | |
| 23 | 24 | import { actRow, rowId } from "./review_act.ts"; |
| 24 | 25 | import { reviewSet } from "./review_set.ts"; |
| 25 | 26 | import { staleArchive } from "./review_stale.ts"; |
| @@ -121,7 +122,9 @@ export function reviewPage(ctx: Ctx): Response { | ||
| 121 | 122 | : stat === "failed" |
| 122 | 123 | ? " · Diff size unavailable — open the pull to see it" |
| 123 | 124 | : ""; |
| 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)); | |
| 125 | 128 | const step = a.pull === undefined ? null : stepOfStored(ctx.app.db, a.repo, a.pull); |
| 126 | 129 | return row({ |
| 127 | 130 | href, |
test/palette-roster.test.ts +1
| @@ -72,6 +72,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 72 | 72 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 73 | 73 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 74 | 74 | ["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"], | |
| 75 | 76 | ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"], |
| 76 | 77 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 77 | 78 | ["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 | }); |
new above
offline
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.