Public: anyone can read this; the crew can change it.
Egress enforcement cannot be turned to report by environment alone #10784
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10778-egress-enforcement-cannot-be-turned-to-r → main 4 commits fork 4 threads · 4 unresolved
docs/APPS.md +5 −3
| @@ -672,8 +672,10 @@ purpose: a repository admin who is no site admin sees the list read-only on the | ||
| 672 | 672 | page, and nothing on the terminal screen (no rows, no `g`/`x`). |
| 673 | 673 | |
| 674 | 674 | A release that turns egress on must not break an app whose writers have |
| 675 | not declared what it uses yet, so the operator can start it reported: | |
| 676 | `FORGE_CELLS_EGRESS=report` lets an undeclared public host through (on 80 | |
| 675 | not declared what it uses yet, so an admin can set it reported: | |
| 676 | `cells.egress` = `report` on /settings/capacity (or the API's | |
| 677 | `PUT /api/v1/admin/capacity/cells.egress` with a `reason`; the change log | |
| 678 | keeps who and why) lets an undeclared public host through (on 80 | |
| 677 | 679 | and 443 only) and |
| 678 | 680 | counts it per repository (`<data>/egress/<repo id>.json`, under the |
| 679 | 681 | forge's data where no cell sees it), and the app's page tells its writers |
| @@ -1655,7 +1657,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1655 | 1657 | | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) | |
| 1656 | 1658 | | `FORGE_CHECK_CACHE_DAYS` | `30` | days a package may go unlent before the check install cache drops it | |
| 1657 | 1659 | | `FORGE_CHECK_CACHE_MB` | `2048` | size one repository's check install cache is swept down to | |
| 1658 | | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) | | |
| 1660 | | `FORGE_CELLS_EGRESS` | ignored | logged once at boot; egress is enforced unless an admin sets `cells.egress` (Settings → Capacity), with a reason | | |
| 1659 | 1661 | | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only | |
| 1660 | 1662 | | `FORGE_CREW_CPUS` | `4` | cores one crew run may use (`cpu.max` on its own cell cgroup, where the cpu controller is delegated) | |
| 1661 | 1663 | | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise | |
docs/MCP.md +3 −3
| @@ -70,7 +70,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 70 | 70 | | `boards` | — | The forge's boards for person admins: slug, title, range, refresh and panels | 425 | |
| 71 | 71 | | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 | |
| 72 | 72 | | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 | |
| 73 | | `capacity` | — | Forge capacity and guards, live | 363 | | |
| 73 | | `capacity` | — | Forge capacity and guards, live | 390 | | |
| 74 | 74 | | `claim_link` | — | A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops | 289 | |
| 75 | 75 | | `close` | — | Close or reopen (`state` open) an issue or pull | 463 | |
| 76 | 76 | | `cloud_account` | — | Change a cloud account by `verb` (admin) | 549 | |
| @@ -79,7 +79,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 79 | 79 | | `confirm_wait` | — | Let an issue go ahead though its blocker closed dropped or split | 269 | |
| 80 | 80 | | `contribute` | — | For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose | 210 | |
| 81 | 81 | | `converge` | — | Have a fork's crew merge the parent's updates where both changed a file | 200 | |
| 82 | | `crew` | — | List a repository's crew, or add or remove an agent | 313 | | |
| 82 | | `crew` | — | List a repository's crew, or add or remove an agent | 338 | | |
| 83 | 83 | | `data` | — | Read SQLite files you may see: tables, rows, read-only SQL; secrets masked | 367 | |
| 84 | 84 | | `decisions_by` | — | Who decides parked hand-over cards: `me`, `decider`, `decider_tell` | 305 | |
| 85 | 85 | | `detach` | — | Detach a fork from its parent | 210 | |
| @@ -89,7 +89,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 89 | 89 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | |
| 90 | 90 | | `email_verify` | — | Send yourself a new verification link | 126 | |
| 91 | 91 | | `events` | — | Repository events | 259 | |
| 92 | | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 414 | | |
| 92 | | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 547 | | |
| 93 | 93 | | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 | |
| 94 | 94 | | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 | |
| 95 | 95 | | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 | |
src/actions/capacity.ts +16 −3
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | type CapacityKey, |
| 23 | 23 | effectiveCapacity, |
| 24 | 24 | followsSpeed, |
| 25 | needsReason, | |
| 26 | REASON_MAX, | |
| 25 | 27 | } from "../core/capacity.ts"; |
| 26 | 28 | import type { EgressTotal } from "../apps/egress.ts"; |
| 27 | 29 | import { |
| @@ -161,15 +163,23 @@ export function edgeOffDryRun( | ||
| 161 | 163 | }); |
| 162 | 164 | } |
| 163 | 165 | |
| 164 | /** Set `key` to `value`; `null` or `""` goes back to the boot default. */ | |
| 166 | /** What a move to report, said without a reason, is told. */ | |
| 167 | export const REASON_WORDS = "Say why: setting cells.egress to report is kept with who made it and the reason."; | |
| 168 | ||
| 169 | /** | |
| 170 | * Set `key` to `value`; `null` or `""` goes back to the boot default. | |
| 171 | * `reason` is kept beside who; a move of `cells.egress` to report is refused | |
| 172 | * without one (#10778). Tightening (enforce, or back to the default) never is. | |
| 173 | */ | |
| 165 | 174 | export function changeCapacity( |
| 166 | 175 | app: App, |
| 167 | 176 | actor: User | undefined, |
| 168 | 177 | key: string, |
| 169 | 178 | value: string | null, |
| 179 | reason = "", | |
| 170 | 180 | ): Result< |
| 171 | 181 | { key: CapacityKey; from: string; to: string }, |
| 172 | "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived" | |
| 182 | "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived" | "reason_required" | |
| 173 | 183 | > { |
| 174 | 184 | const who = admin(actor); |
| 175 | 185 | if (!who.ok) return who; |
| @@ -180,16 +190,19 @@ export function changeCapacity( | ||
| 180 | 190 | return err("invalid_value"); |
| 181 | 191 | if (key === "signin.edge" && next === "off" && !signsInWithoutEdge(app.db, who.value.id)) |
| 182 | 192 | return err("no_credential"); |
| 193 | const why = reason.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, REASON_MAX); | |
| 194 | if (needsReason(key) && next === "report" && why === "") return err("reason_required"); | |
| 183 | 195 | const changed = setCapacity( |
| 184 | 196 | app.db, |
| 185 | 197 | key, |
| 186 | 198 | next === "" ? null : next, |
| 187 | 199 | who.value.id, |
| 200 | why, | |
| 188 | 201 | ); |
| 189 | 202 | app.bus.append({ |
| 190 | 203 | kind: "capacity.changed", |
| 191 | 204 | actorId: who.value.id, |
| 192 | payload: { key, from: changed.from, to: changed.to }, | |
| 205 | payload: { key, from: changed.from, to: changed.to, ...(why === "" ? {} : { reason: why }) }, | |
| 193 | 206 | }); |
| 194 | 207 | return ok({ key, ...changed }); |
| 195 | 208 | } |
src/apps/contain.ts +4 −13
| @@ -241,8 +241,8 @@ export interface CellSpec { | ||
| 241 | 241 | readonly onRefused?: ((host: string, port: number, isPrivate: boolean) => void) | undefined; |
| 242 | 242 | /** |
| 243 | 243 | * Refuse undeclared egress, or only report it, for this cell — the |
| 244 | * forge's setting when the cell starts (`core/capacity.ts`); the | |
| 245 | * environment's (`egressMode`) when the caller has none. | |
| 244 | * forge's setting when the cell starts (`core/capacity.ts`); enforce | |
| 245 | * when the caller has none. The environment never says (#10778). | |
| 246 | 246 | */ |
| 247 | 247 | readonly egressMode?: "enforce" | "report" | undefined; |
| 248 | 248 | /** |
| @@ -274,16 +274,6 @@ export function watchedLimits(limits: Limits, cgroup: { readonly cpuCapped: bool | ||
| 274 | 274 | return cgroup?.cpuCapped === true ? { ...limits, cpus: Number.POSITIVE_INFINITY } : limits; |
| 275 | 275 | } |
| 276 | 276 | |
| 277 | /** | |
| 278 | * Whether undeclared egress is refused (`enforce`, the default) or let | |
| 279 | * through and reported (`FORGE_CELLS_EGRESS=report`), for a release that | |
| 280 | * must not break an app before its writers have declared what it uses. | |
| 281 | * Files, processes and private addresses are held the same in both. | |
| 282 | */ | |
| 283 | export function egressMode(): "enforce" | "report" { | |
| 284 | return process.env["FORGE_CELLS_EGRESS"] === "report" ? "report" : "enforce"; | |
| 285 | } | |
| 286 | ||
| 287 | 277 | export interface Cell { |
| 288 | 278 | /** The argv that runs `command` (through `sh -c`) inside the cell. */ |
| 289 | 279 | readonly argv: (command: string) => string[]; |
| @@ -524,7 +514,8 @@ export function openCell( | ||
| 524 | 514 | // the forge told its parent's door; a check that boots a forge is not. |
| 525 | 515 | upstream: process.env["FORGE_PARENT_LOCAL"] === undefined ? undefined : upstreamProxy(), |
| 526 | 516 | report: |
| 527 | (spec.egressMode ?? egressMode()) === "report" | |
| 517 | // Enforce unless the caller carries an admin's report (`egressSetting`, #10778). | |
| 518 | spec.egressMode === "report" | |
| 528 | 519 | ? (host, port) => spec.onUndeclared?.(host, port) |
| 529 | 520 | : undefined, |
| 530 | 521 | }, |
src/apps/egress.ts +1 −1
| @@ -33,7 +33,7 @@ export interface EgressPolicy { | ||
| 33 | 33 | */ |
| 34 | 34 | readonly granted?: readonly string[] | undefined; |
| 35 | 35 | /** |
| 36 | * Set while egress is only reported (`FORGE_CELLS_EGRESS=report`): an | |
| 36 | * Set while egress is only reported (`cells.egress` = `report`, an admin's setting): an | |
| 37 | 37 | * undeclared public host is let through and handed here instead of |
| 38 | 38 | * refused. Private addresses are refused either way. |
| 39 | 39 | */ |
src/apps/egress_report.ts +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What a repository's code reached without declaring it, while egress is |
| 3 | * only reported (`FORGE_CELLS_EGRESS=report`, `apps/contain.ts`): one small | |
| 3 | * only reported (`cells.egress` = `report`, `core/capacity.ts`): one small | |
| 4 | 4 | * file per repository under `<data>/egress/`, read by the app's page so its |
| 5 | 5 | * writers know what to add to `forge.json` before egress is enforced. |
| 6 | 6 | */ |
src/core/capacity.ts +40 −9
| @@ -33,7 +33,7 @@ export type CapacityKey = | ||
| 33 | 33 | interface Definition { |
| 34 | 34 | /** What the settings page and the API call it. */ |
| 35 | 35 | readonly label: string; |
| 36 | /** The environment variable that is its boot default. */ | |
| 36 | /** The environment variable that is its boot default; "" for a setting only an admin moves. */ | |
| 37 | 37 | readonly env: string; |
| 38 | 38 | /** Whole numbers in a range, or one of a few words. */ |
| 39 | 39 | readonly kind: |
| @@ -100,10 +100,12 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 100 | 100 | }, |
| 101 | 101 | "cells.egress": { |
| 102 | 102 | label: "Egress from cells", |
| 103 | env: "FORGE_CELLS_EGRESS", | |
| 103 | // No environment default: report is a decision an admin makes and the | |
| 104 | // change log keeps, never what a deployment's environment says (#10778). | |
| 105 | env: "", | |
| 104 | 106 | kind: { type: "choice", values: ["enforce", "report"] }, |
| 105 | 107 | fallback: "enforce", |
| 106 | help: "enforce refuses a host an app or check did not declare; report lets it through on 80 and 443 and lists it on the app's page. Takes effect on the next cell started.", | |
| 108 | help: "enforce refuses a host an app or check did not declare; report lets it through on 80 and 443 and lists it on the app's page. Enforce unless an admin sets report here, with a reason. Takes effect on the next cell started.", | |
outdated
Principles 1 and 8: this help already says what enforce and report do; the added clause and the page's "The default; only an admin moves it." repeat one another. Say it once: "Enforce unless an admin sets report here, with a reason." |
||
| 107 | 109 | }, |
| 108 | 110 | "children.modelEgress": { |
| 109 | 111 | label: "Model provider a child forge cell may reach", |
| @@ -178,7 +180,9 @@ export function capacityDefault(key: CapacityKey, cpus?: number): string { | ||
| 178 | 180 | |
| 179 | 181 | /** The environment's value for `key`, when it holds a valid one. */ |
| 180 | 182 | function explicitDefault(key: CapacityKey): string | undefined { |
| 181 | const env = process.env[CAPACITY[key].env]?.trim() ?? ""; | |
| 183 | const name = CAPACITY[key].env; | |
| 184 | if (name === "") return undefined; | |
| 185 | const env = process.env[name]?.trim() ?? ""; | |
| 182 | 186 | return env !== "" && validCapacity(key, env) ? env : undefined; |
| 183 | 187 | } |
| 184 | 188 | |
| @@ -391,11 +395,32 @@ export function maxChildren(db: Database): number { | ||
| 391 | 395 | return Number(capacityValue(db, "children.max")); |
| 392 | 396 | } |
| 393 | 397 | |
| 394 | /** The egress mode a cell starts with now. */ | |
| 398 | /** The egress mode a cell starts with now: enforce unless an admin set report. */ | |
| 395 | 399 | export function egressSetting(db: Database): "enforce" | "report" { |
| 396 | 400 | return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce"; |
| 397 | 401 | } |
| 398 | 402 | |
| 403 | /** The variable that once set the egress mode, and is now only noticed (#10778). */ | |
| 404 | export const EGRESS_ENV = "FORGE_CELLS_EGRESS"; | |
| 405 | ||
| 406 | /** | |
| 407 | * The one boot line for an environment that still asks for an egress mode: | |
| 408 | * it is ignored, and this says so once, plainly; null when it asks nothing. | |
| 409 | */ | |
| 410 | export function egressEnvIgnored(env: Record<string, string | undefined> = process.env): string | null { | |
| 411 | const asked = env[EGRESS_ENV]?.trim() ?? ""; | |
| 412 | if (asked === "" || asked === "enforce") return null; | |
| 413 | return `${EGRESS_ENV}=${asked.slice(0, 32)} is ignored: egress is enforced unless an admin sets cells.egress on /settings/capacity, with a reason`; | |
| 414 | } | |
| 415 | ||
| 416 | /** Most characters a change's reason keeps. */ | |
| 417 | export const REASON_MAX = 500; | |
| 418 | ||
| 419 | /** Keys whose loosening must say why: a move of these to report needs a reason. */ | |
| 420 | export function needsReason(key: CapacityKey): boolean { | |
| 421 | return key === "cells.egress"; | |
| 422 | } | |
| 423 | ||
| 399 | 424 | /** The model provider hosts a child forge's cell may reach now: none unless an admin chose one. */ |
| 400 | 425 | export function childModelHosts(db: Database): readonly string[] { |
| 401 | 426 | return MODEL_HOSTS[capacityValue(db, "children.modelEgress")] ?? []; |
| @@ -466,6 +491,8 @@ export interface CapacityChange { | ||
| 466 | 491 | readonly actorId: string; |
| 467 | 492 | readonly actorName: string; |
| 468 | 493 | readonly at: string; |
| 494 | /** Why, as the admin said it; "" for a change made before reasons were kept. */ | |
| 495 | readonly reason: string; | |
| 469 | 496 | } |
| 470 | 497 | |
| 471 | 498 | /** |
| @@ -479,6 +506,8 @@ export function setCapacity( | ||
| 479 | 506 | key: CapacityKey, |
| 480 | 507 | value: string | null, |
| 481 | 508 | actorId: string, |
| 509 | /** Why, in the admin's words, already cut to REASON_MAX by the action; kept beside who (#10778). */ | |
| 510 | reason = "", | |
| 482 | 511 | ): { from: string; to: string } { |
| 483 | 512 | const from = capacityValue(db, key); |
| 484 | 513 | const at = new Date().toISOString(); |
| @@ -494,9 +523,9 @@ export function setCapacity( | ||
| 494 | 523 | })(); |
| 495 | 524 | const to = capacityValue(db, key); |
| 496 | 525 | db.query( |
| 497 | `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at) | |
| 498 | VALUES (?1, ?2, ?3, ?4, ?5)`, | |
| 499 | ).run(key, from, to, actorId, at); | |
| 526 | `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at, reason) | |
| 527 | VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, | |
| 528 | ).run(key, from, to, actorId, at, reason); | |
| 500 | 529 | return { from, to }; |
| 501 | 530 | } |
| 502 | 531 | |
| @@ -511,10 +540,11 @@ export function capacityChanges(db: Database, limit = 20): CapacityChange[] { | ||
| 511 | 540 | actor_id: string; |
| 512 | 541 | username: string | null; |
| 513 | 542 | at: string; |
| 543 | reason: string; | |
| 514 | 544 | }, |
| 515 | 545 | [number] |
| 516 | 546 | >( |
| 517 | `SELECT c.key, c.from_value, c.to_value, c.actor_id, u.username, c.at | |
| 547 | `SELECT c.key, c.from_value, c.to_value, c.actor_id, u.username, c.at, c.reason | |
| 518 | 548 | FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id |
| 519 | 549 | ORDER BY c.at DESC, c.rowid DESC LIMIT ?1`, |
| 520 | 550 | ) |
| @@ -527,5 +557,6 @@ export function capacityChanges(db: Database, limit = 20): CapacityChange[] { | ||
| 527 | 557 | actorId: r.actor_id, |
| 528 | 558 | actorName: r.username ?? "", |
| 529 | 559 | at: r.at, |
| 560 | reason: r.reason, | |
| 530 | 561 | })); |
| 531 | 562 | } |
src/core/migration-pins/20261110140000-setting-change-reason.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 03b5a26202aad083af3b64356cc064faec8f1f275521c22beceef313f3f4612d |
src/core/migrations/20261110140000-setting-change-reason.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // A capacity change keeps why beside who (#10778): cells.egress moves to | |
| 2 | // report only with a reason, and the change log shows it. Additive. | |
| 3 | export default ` | |
| 4 | ALTER TABLE forge_setting_change ADD COLUMN reason TEXT NOT NULL DEFAULT ''; | |
| 5 | `; |
src/http/api/admin.ts +6 −3
| @@ -13,7 +13,7 @@ import { kpiProbe } from "../../web/studio/kpi_probe.ts"; | ||
| 13 | 13 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| 14 | 14 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| 15 | 15 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; |
| 16 | import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts"; | |
| 16 | import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts"; | |
| 17 | 17 | import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts"; |
| 18 | 18 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 19 | 19 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| @@ -188,7 +188,8 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 188 | 188 | return dry.ok ? json({ dry_run: true, ...dry.value }) : notFound(); |
| 189 | 189 | } |
| 190 | 190 | const value = input.value === null ? null : str(input.value); |
| 191 | const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value); | |
| 191 | const reason = typeof input.reason === "string" ? input.reason : ""; | |
| 192 | const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value, reason); | |
| 192 | 193 | if (changed.ok) return json(changed.value); |
| 193 | 194 | if (changed.error === "not_found") return notFound(); |
| 194 | 195 | return apiError( |
| @@ -198,7 +199,9 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 198 | 199 | ? "no such capacity setting" |
| 199 | 200 | : changed.error === "derived" |
| 200 | 201 | ? DERIVED_WORDS |
| 201 | : SETTING_WORDS.outOfRange, | |
| 202 | : changed.error === "reason_required" | |
| 203 | ? REASON_WORDS | |
| 204 | : SETTING_WORDS.outOfRange, | |
| 202 | 205 | ); |
| 203 | 206 | }); |
| 204 | 207 |
src/http/mcp/tools/capacity.ts +10 −4
| @@ -1,11 +1,11 @@ | ||
| 1 | import { DERIVED_WORDS, SETTABLE_CAPACITY, changeCapacity, listCapacity } from "../../../actions/capacity.ts"; | |
| 1 | import { DERIVED_WORDS, REASON_WORDS, SETTABLE_CAPACITY, changeCapacity, listCapacity } from "../../../actions/capacity.ts"; | |
| 2 | 2 | import { SETTING_WORDS } from "../../../actions/setting_words.ts"; |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "capacity", |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`, and `selfSwap: { enabled, migrateReq, blocked }`: whether `FORGE_SELF_SWAP=1`, the kernel's `tcp_migrate_req` (`1`, `0`, `unreadable`), and why this forge cannot swap itself, null when it can; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 8 | doc: "`{ key?, value?, reason?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`, and `selfSwap: { enabled, migrateReq, blocked }`: whether `FORGE_SELF_SWAP=1`, the kernel's `tcp_migrate_req` (`1`, `0`, `unreadable`), and why this forge cannot swap itself, null when it can; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default) and `reason` (kept with who; required for `cells.egress`), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 9 | 9 | description: |
| 10 | 10 | "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.", |
| 11 | 11 | inputSchema: { |
| @@ -16,6 +16,7 @@ export const tool: Tool = { | ||
| 16 | 16 | enum: [...SETTABLE_CAPACITY], |
| 17 | 17 | }, |
| 18 | 18 | value: { type: "string", maxLength: 16 }, |
| 19 | reason: { type: "string", maxLength: 500 }, | |
| 19 | 20 | dryRun: { type: "boolean" }, |
| 20 | 21 | }, |
| 21 | 22 | }, |
| @@ -35,8 +36,13 @@ export const tool: Tool = { | ||
| 35 | 36 | }); |
| 36 | 37 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) |
| 37 | 38 | return bad("forbidden", "changing the forge's capacity needs an admin-scope token"); |
| 38 | const changed = changeCapacity(ctx.app, user, key, value); | |
| 39 | if (!changed.ok) return bad(changed.error, changed.error === "derived" ? DERIVED_WORDS : SETTING_WORDS.outOfRange); | |
| 39 | const reason = typeof args["reason"] === "string" ? args["reason"] : ""; | |
| 40 | const changed = changeCapacity(ctx.app, user, key, value, reason); | |
| 41 | if (!changed.ok) | |
| 42 | return bad( | |
| 43 | changed.error, | |
| 44 | changed.error === "derived" ? DERIVED_WORDS : changed.error === "reason_required" ? REASON_WORDS : SETTING_WORDS.outOfRange, | |
| 45 | ); | |
| 40 | 46 | return good({ |
| 41 | 47 | says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`, |
| 42 | 48 | ...changed.value, |
src/main.ts +4 −1
| @@ -93,7 +93,7 @@ import { ownTagsReader } from "./release/own_tags.ts"; | ||
| 93 | 93 | import { rollProbe, tick as keeperTick } from "./keeper/keeper.ts"; |
| 94 | 94 | import { keeperEnv, keeperPorts } from "./keeper/wire.ts"; |
| 95 | 95 | import { snapshotPlace } from "./actions/health.ts"; |
| 96 | import { capacityDefault } from "./core/capacity.ts"; | |
| 96 | import { capacityDefault, egressEnvIgnored } from "./core/capacity.ts"; | |
| 97 | 97 | import { bootCounts, recordBoot, recordCleanExit } from "./kernel/boots.ts"; |
| 98 | 98 | import { census, censusWords, processRows } from "./kernel/census.ts"; |
| 99 | 99 | import { reapLeftoverBridges } from "./crew/leftovers.ts"; |
| @@ -397,6 +397,9 @@ const publicUrl = new URL(forgeUrl); | ||
| 397 | 397 | if (appDomain?.ok === false) app.log.error(appDomain.reason); |
| 398 | 398 | if (appDomain?.ok === true && appDomain.note !== undefined) |
| 399 | 399 | app.log.warn(appDomain.note); |
| 400 | // An environment asking for egress report is ignored, and said so once (#10778). | |
| 401 | const egressIgnored = egressEnvIgnored(); | |
| 402 | if (egressIgnored !== null) app.log.info(egressIgnored); | |
| 400 | 403 | // One crew run's length: what a run takes, and how long past its idle |
| 401 | 404 | // window an app busy with one is left awake. |
| 402 | 405 | const crewRunMs = positive(process.env.FORGE_CREW_TIMEOUT_MIN, 45) * 60_000; |
src/tui/screens/capacity.ts +23 −8
| @@ -2,13 +2,14 @@ | ||
| 2 | 2 | * The forge's capacity and guards in the terminal, a forge admin's who is a |
| 3 | 3 | * person: each guard's live value and bounds, and `e` on a settable one asks |
| 4 | 4 | * for a value, shows the plan (from → to) and changes it after a confirm, |
| 5 | * through the same actions as MCP `capacity`. The actions decide who may. | |
| 5 | * through the same actions as MCP `capacity`; a move of cells.egress to | |
| 6 | * report asks why first, as the page does. The actions decide who may. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import type { SelfSwap } from "../../release/swap.ts"; |
| 9 | import { DERIVED_WORDS, changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 10 | import { DERIVED_WORDS, REASON_WORDS, changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 10 | 11 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 11 | import { CAPACITY, belowCeiling, validCapacity, type CapacityKey } from "../../core/capacity.ts"; | |
| 12 | import { CAPACITY, belowCeiling, needsReason, validCapacity, type CapacityKey } from "../../core/capacity.ts"; | |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 14 | import { binding } from "../app/keys.ts"; |
| 14 | 15 | import type { Session } from "../session.ts"; |
| @@ -29,8 +30,10 @@ export function boundsOf(key: CapacityKey): string { | ||
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | /** What a refused change says, in its own words. */ |
| 32 | const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived"): string => | |
| 33 | error === "derived" | |
| 33 | const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived" | "reason_required"): string => | |
| 34 | error === "reason_required" | |
outdated
Both doors: the terminal refuses and sends the person to the page. The screen must collect the reason itself and call changeCapacity, so a person can do in the terminal what the page does. Add a test in test/tui-capacity.test.ts. |
||
| 35 | ? REASON_WORDS | |
| 36 | : error === "derived" | |
| 34 | 37 | ? DERIVED_WORDS |
| 35 | 38 | : error === "no_credential" |
| 36 | 39 | ? "Set a password or a passkey first: turning the edge off would lock you out" |
| @@ -72,17 +75,17 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen { | ||
| 72 | 75 | return [...guards, selfSwapRow(listed.value.selfSwap), ...(egress === null ? [] : [{ title: egress, tone: "attn" as const }])]; |
| 73 | 76 | }; |
| 74 | 77 | |
| 75 | const plan = (row: CapacityRow, typed: string): void => { | |
| 78 | const plan = (row: CapacityRow, typed: string, reason = ""): void => { | |
| 76 | 79 | const to = typed === "" ? row.default : typed; |
| 77 | 80 | later(ctx, () => |
| 78 | 81 | confirmScreen(ctx, screen, { |
| 79 | 82 | title: "Capacity", |
| 80 | body: [`${row.key}: ${shown(row.value)} → ${shown(to)}`, "change it? y/n"], | |
| 83 | body: [`${row.key}: ${shown(row.value)} → ${shown(to)}`, ...(reason === "" ? [] : [`why: ${reason}`]), "change it? y/n"], | |
| 81 | 84 | actions: [{ label: "y" }, { label: "n" }], |
| 82 | 85 | selected: 1, |
| 83 | 86 | onChoose: (choice) => { |
| 84 | 87 | if (choice !== 0) return; |
| 85 | const done = changeCapacity(f.app, f.user, row.key, typed); | |
| 88 | const done = changeCapacity(f.app, f.user, row.key, typed, reason); | |
| 86 | 89 | if (!done.ok) return ctx.toast(refused(done.error), "danger"); |
| 87 | 90 | ctx.toast(`${done.value.key}: ${shown(done.value.from)} → ${shown(done.value.to)}, from the next pick on.`, "success"); |
| 88 | 91 | ctx.invalidate(); |
| @@ -91,6 +94,16 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen { | ||
| 91 | 94 | ); |
| 92 | 95 | }; |
| 93 | 96 | |
| 97 | const askWhy = (row: CapacityRow, value: string): void => { | |
| 98 | void later(ctx, () => | |
| 99 | promptScreen(ctx, screen, { | |
| 100 | title: row.label, | |
| 101 | label: `Why set ${row.key} to ${value}? (kept with your name)`, | |
| 102 | onSubmit: (why) => (why === "" ? REASON_WORDS : (plan(row, value, why), undefined)), | |
| 103 | }), | |
| 104 | ); | |
| 105 | }; | |
| 106 | ||
| 94 | 107 | const screen: Screen = { |
| 95 | 108 | title: "Capacity", |
| 96 | 109 | keys: [binding(["e"], "change a setting"), binding(["j", "k"], "move cursor")], |
| @@ -113,6 +126,8 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen { | ||
| 113 | 126 | onSubmit: (typed) => { |
| 114 | 127 | const value = typed.trim(); |
| 115 | 128 | if (value !== "" && !validCapacity(row.key, value)) return SETTING_WORDS.outOfRange; |
| 129 | // A move to report says why, as the page asks (#10778); tightening never waits. | |
| 130 | if (needsReason(row.key) && value === "report") return askWhy(row, value), undefined; | |
| 116 | 131 | return plan(row, value), undefined; |
| 117 | 132 | }, |
| 118 | 133 | }), |
src/web/pages/capacity.ts +25 −6
| @@ -6,8 +6,8 @@ | ||
| 6 | 6 | * and each one is listed below the settings: who, when, from what to what. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 10 | import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts"; | |
| 9 | import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 10 | import { CAPACITY, REASON_MAX, belowCeiling, needsReason, type CapacityKey } from "../../core/capacity.ts"; | |
| 11 | 11 | import { EGRESS_REPORTED } from "../../system/egress_line.ts"; |
| 12 | 12 | import { bytes } from "../../system/format.ts"; |
| 13 | 13 | import { egressRowsFor } from "../../spawn/process_counters.ts"; |
| @@ -100,11 +100,14 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 100 | 100 | )}`, |
| 101 | 101 | meta: html`${s.help} <br />Now <span class="mono">${s.value === "" ? "auto" : s.value}</span>. ${s.source === "setting" |
| 102 | 102 | ? html`Set here; the boot default is |
| 103 | <span class="mono">${s.default === "" ? "auto" : s.default}</span> | |
| 104 | (<span class="mono">${s.env}</span>).` | |
| 103 | <span class="mono">${s.default === "" ? "auto" : s.default}</span>${s.env === "" | |
| 104 | ? "." | |
| 105 | : html` (<span class="mono">${s.env}</span>).`}` | |
| 105 | 106 | : s.source === "derived" |
| 106 | 107 | ? html`Derived from Speed (<span class="mono">crew.concurrency</span>); read-only here: change Speed and it moves.` |
| 107 | : html`The boot default, from <span class="mono">${s.env}</span>.`} | |
| 108 | : s.env === "" | |
| 109 | ? html`The default.` | |
| 110 | : html`The boot default, from <span class="mono">${s.env}</span>.`} | |
| 108 | 111 | ${awayLine(s, now)}`, |
| 109 | 112 | trail: s.source === "derived" |
| 110 | 113 | ? html`<span class="mono" data-derived="${s.key}">${s.value}</span>` |
| @@ -115,6 +118,20 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 115 | 118 | > |
| 116 | 119 | <input type="hidden" name="key" value="${s.key}" /> |
| 117 | 120 | ${input(s.key, s.value)} |
| 121 | ${when( | |
The reason field is required on the Save form, but the Put back form carries none, so resetting cells.egress now fails with reason_required. Once the action only asks a reason for report this holds; add a page-level test that POSTs without a reason (refused) and a reset (accepted). Also principle 6/3: a bare |
||
| 122 | // Inside the row's own form, posted with Save: a move to report says why (#10778). | |
| 123 | needsReason(s.key), | |
| 124 | () => html`<input | |
| 125 | class="input" | |
| 126 | id="cap-${s.key}-reason" | |
| 127 | type="text" | |
| 128 | name="reason" | |
| 129 | maxlength="${String(REASON_MAX)}" | |
| 130 | autocomplete="off" | |
| 131 | placeholder="Why (needed to set report)" | |
| 132 | aria-label="Why (needed to set report)" | |
| 133 | />`, | |
| 134 | )} | |
| 118 | 135 | ${btn("Save", { |
| 119 | 136 | size: "sm", |
| 120 | 137 | variant: "primary", |
| @@ -142,7 +159,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 142 | 159 | title: html`<span class="mono">${c.key}</span> |
| 143 | 160 | <span class="mono">${c.from === "" ? "auto" : c.from}</span> → |
| 144 | 161 | <span class="mono">${c.to === "" ? "auto" : c.to}</span>`, |
| 145 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}`, | |
| 162 | meta: html`${c.actorName === "" ? "someone" : c.actorName} · ${time(c.at)}${c.reason === "" ? "" : html` · ${c.reason}`}`, | |
| 146 | 163 | }), |
| 147 | 164 | ); |
| 148 | 165 | const reported = settings.some( |
| @@ -239,6 +256,7 @@ const ERRORS: Record<string, string> = { | ||
| 239 | 256 | no_such_setting: "There is no such setting.", |
| 240 | 257 | derived: DERIVED_WORDS, |
| 241 | 258 | invalid_value: "That value is outside what this setting takes.", |
| 259 | reason_required: REASON_WORDS, | |
| 242 | 260 | no_credential: |
| 243 | 261 | "Add a passkey (or a password) to your own account first: with the edge off, you would be signed out for good.", |
| 244 | 262 | }; |
| @@ -268,6 +286,7 @@ export async function capacitySet(ctx: Ctx): Promise<Response> { | ||
| 268 | 286 | ctx.auth.user, |
| 269 | 287 | key, |
| 270 | 288 | reset ? null : field(form, "value"), |
| 289 | field(form, "reason"), | |
| 271 | 290 | ); |
| 272 | 291 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); |
| 273 | 292 | return flashTo( |
src/web/studio/egress.ts +1 −2
| @@ -6,7 +6,6 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts"; |
| 9 | import { egressMode } from "../../apps/contain.ts"; | |
| 10 | 9 | import { html, type Html } from "../html.ts"; |
| 11 | 10 | import { row } from "../ui.ts"; |
| 12 | 11 | |
| @@ -16,7 +15,7 @@ export function egressNote( | ||
| 16 | 15 | /** What production declares now: a host declared since is not repeated. */ |
| 17 | 16 | declared: readonly string[], |
| 18 | 17 | /** The forge's egress mode now (`core/capacity.ts`). */ |
| 19 | mode: "enforce" | "report" = egressMode(), | |
| 18 | mode: "enforce" | "report", | |
| 20 | 19 | ): Html | null { |
| 21 | 20 | // Once egress is enforced the forge refuses these calls; the note is for the |
| 22 | 21 | // window before that, when adding them to forge.json still saves the app. |
test/board.test.ts +1 −1
| @@ -716,7 +716,7 @@ describe("the board's forge strip", () => { | ||
| 716 | 716 | claimRun(app.db, createRun(app.db, { repoId: made.value.id, kind: "agent" }).id); |
| 717 | 717 | // Clear the stuck run the first test left behind. |
| 718 | 718 | app.db.query("UPDATE run SET state = 'failed' WHERE heartbeat_at = ?1").run("2020-01-01T00:00:00.000Z"); |
| 719 | expect(changeCapacity(app, admin, "cells.egress", "report").ok).toBe(true); | |
| 719 | expect(changeCapacity(app, admin, "cells.egress", "report", "a test says why").ok).toBe(true); | |
| 720 | 720 | try { |
| 721 | 721 | const text = await board("root"); |
| 722 | 722 | expect(text).toContain("All fine"); |
test/calm-banners.test.ts +1 −1
| @@ -63,7 +63,7 @@ test("with egress enforced, System and Capacity say nothing about it, and neithe | ||
| 63 | 63 | }); |
| 64 | 64 | |
| 65 | 65 | test("egress in report mode is one line on Capacity and System, not on / or /board", async () => { |
| 66 | const changed = await changeCapacity(app, admin.value, "cells.egress", "report"); | |
| 66 | const changed = await changeCapacity(app, admin.value, "cells.egress", "report", "a test says why"); | |
| 67 | 67 | expect(changed.ok).toBe(true); |
| 68 | 68 | expect(await get("/settings/capacity")).toContain('id="egress-reported"'); |
| 69 | 69 | expect(await get("/system")).toContain('id="egress-reported"'); |
test/capacity-rows.test.ts +1 −1
| @@ -63,7 +63,7 @@ test("the egress row reads its true current state, and the reported note says so | ||
| 63 | 63 | const before = await page(); |
| 64 | 64 | expect(before).not.toContain('id="egress-reported"'); |
| 65 | 65 | const now = /Now <span class="mono">([^<]+)<\/span>/.exec(egressRow(before))?.[1]; |
| 66 | expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true); | |
| 66 | expect(changeCapacity(app, alice, "cells.egress", "report", "a test says why").ok).toBe(true); | |
| 67 | 67 | const after = await page(); |
| 68 | 68 | expect(egressRow(after)).toContain('Now <span class="mono">report</span>'); |
| 69 | 69 | expect(now).not.toBe("report"); |
test/capacity.test.ts +3 −2
| @@ -302,6 +302,7 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 302 | 302 | const made = await mcp(aliceAdmin, { |
| 303 | 303 | key: "cells.egress", |
| 304 | 304 | value: "report", |
| 305 | reason: "a test says why", | |
| 305 | 306 | dryRun: false, |
| 306 | 307 | }); |
| 307 | 308 | expect(made.error).toBe(false); |
| @@ -457,7 +458,7 @@ describe("report mode is loud to admins, and to nobody else", () => { | ||
| 457 | 458 | }); |
| 458 | 459 | |
| 459 | 460 | test("reported: the Capacity page says it once, as a note; vitals do not", async () => { |
| 460 | expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true); | |
| 461 | expect(changeCapacity(app, alice, "cells.egress", "report", "a test says why").ok).toBe(true); | |
| 461 | 462 | expect(needs(alice)).toEqual([]); |
| 462 | 463 | const shown = await page(); |
| 463 | 464 | expect(shown).toContain('id="egress-reported"'); |
| @@ -831,7 +832,7 @@ describe("the egress mode is the emergency lever: the next cell starts with what | ||
| 831 | 832 | if (!record.ok) throw new Error(String(record.error)); |
| 832 | 833 | // A runtime that can hold cells is asked; one that cannot has none to start. |
| 833 | 834 | (runtime as unknown as { cells: { ok: true } }).cells = { ok: true }; |
| 834 | expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe( | |
| 835 | expect(changeCapacity(app, alice, "cells.egress", "report", "a test says why").ok).toBe( | |
| 835 | 836 | true, |
| 836 | 837 | ); |
| 837 | 838 | expect(runtime.cellFor(record.value, "production")?.egressMode).toBe( |
test/contain.test.ts +1 −2
| @@ -906,10 +906,8 @@ describe.skipIf(noCells)( | ||
| 906 | 906 | hostname: "127.0.0.1", |
| 907 | 907 | fetch: () => new Response("inside"), |
| 908 | 908 | }); |
| 909 | process.env["FORGE_CELLS_EGRESS"] = "report"; | |
| 910 | 909 | }); |
| 911 | 910 | afterAll(async () => { |
| 912 | delete process.env["FORGE_CELLS_EGRESS"]; | |
| 913 | 911 | listener.stop(true); |
| 914 | 912 | await rm(dir, { recursive: true, force: true }); |
| 915 | 913 | }); |
| @@ -938,6 +936,7 @@ describe.skipIf(noCells)( | ||
| 938 | 936 | egress: [], |
| 939 | 937 | reach: [], |
| 940 | 938 | limits: { memoryMiB: 1024, cpus: 2, diskMiB: 1024 }, |
| 939 | egressMode: "report", | |
| 941 | 940 | onUndeclared: (host, port) => seen.push(`${host}:${port}`), |
| 942 | 941 | }, |
| 943 | 942 | }, |
test/egress-default.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | // Egress is enforced unless an admin says otherwise, with a reason (#10778): | |
| 2 | // an environment asking for report is ignored and said once, never obeyed. | |
| 3 | import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { changeCapacity } from "../src/actions/capacity.ts"; | |
| 10 | import { | |
| 11 | capacityChanges, | |
| 12 | capacityDefault, | |
| 13 | egressEnvIgnored, | |
| 14 | egressSetting, | |
| 15 | REASON_MAX, | |
| 16 | setCapacity, | |
| 17 | } from "../src/core/capacity.ts"; | |
| 18 | import type { User } from "../src/core/users.ts"; | |
| 19 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 20 | ||
| 21 | let dataDir = ""; | |
| 22 | let app: App, servers: Servers, bearer = "", alice: User; | |
| 23 | const before = process.env["FORGE_CELLS_EGRESS"]; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-egress-default-")); | |
| 27 | app = createApp({ dataDir, port: 0 }); | |
| 28 | servers = await serve(app); | |
| 29 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 30 | if (!admin.ok) throw new Error(admin.error); | |
| 31 | alice = admin.value; | |
| 32 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 33 | method: "POST", | |
| 34 | headers: { "content-type": "application/json" }, | |
| 35 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 36 | }); | |
| 37 | bearer = ((await minted.json()) as { token: string }).token; | |
| 38 | }); | |
| 39 | ||
| 40 | afterEach(() => { | |
| 41 | if (before === undefined) delete process.env["FORGE_CELLS_EGRESS"]; | |
| 42 | else process.env["FORGE_CELLS_EGRESS"] = before; | |
| 43 | setCapacity(app.db, "cells.egress", null, alice.id); | |
| 44 | }); | |
| 45 | ||
| 46 | afterAll(async () => { | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | await rm(dataDir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("the default is enforce", () => { | |
| 53 | delete process.env["FORGE_CELLS_EGRESS"]; | |
| 54 | expect(capacityDefault("cells.egress")).toBe("enforce"); | |
| 55 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("an environment asking for report is ignored, and said in one line", () => { | |
| 59 | process.env["FORGE_CELLS_EGRESS"] = "report"; | |
| 60 | expect(capacityDefault("cells.egress")).toBe("enforce"); | |
| 61 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 62 | const said = egressEnvIgnored(); | |
| 63 | expect(said).toContain("FORGE_CELLS_EGRESS=report is ignored"); | |
| 64 | expect(said).not.toContain("\n"); | |
| 65 | expect(egressEnvIgnored({})).toBeNull(); | |
| 66 | expect(egressEnvIgnored({ FORGE_CELLS_EGRESS: "enforce" })).toBeNull(); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the capacity page shows no report line and no environment for it when only the environment asks", async () => { | |
| 70 | process.env["FORGE_CELLS_EGRESS"] = "report"; | |
| 71 | const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 72 | const body = await res.text(); | |
| 73 | expect(body).not.toContain("Egress is reported, not enforced"); | |
| 74 | expect(body).not.toContain("FORGE_CELLS_EGRESS"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("an admin's report is honoured only with a reason, and kept with who and why", async () => { | |
| 78 | expect(changeCapacity(app, alice, "cells.egress", "report")).toEqual({ ok: false, error: "reason_required" }); | |
| 79 | expect(changeCapacity(app, alice, "cells.egress", "report", " ")).toEqual({ ok: false, error: "reason_required" }); | |
| 80 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 81 | ||
| 82 | expect(changeCapacity(app, alice, "cells.egress", "report", "migrating the lab, one day").ok).toBe(true); | |
| 83 | expect(egressSetting(app.db)).toBe("report"); | |
| 84 | const kept = capacityChanges(app.db)[0]!; | |
| 85 | expect([kept.key, kept.from, kept.to, kept.actorName, kept.reason]).toEqual([ | |
| 86 | "cells.egress", | |
| 87 | "enforce", | |
| 88 | "report", | |
| 89 | "alice", | |
| 90 | "migrating the lab, one day", | |
| 91 | ]); | |
| 92 | ||
| 93 | // The API refuses without a reason and keeps the one it is given. | |
| 94 | const put = (body: object) => | |
| 95 | fetch(`${servers.url}/api/v1/admin/capacity/cells.egress`, { | |
| 96 | method: "PUT", | |
| 97 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, | |
| 98 | body: JSON.stringify(body), | |
| 99 | }); | |
| 100 | expect((await put({ value: "report" })).status).toBe(422); | |
| 101 | expect((await put({ value: "enforce", reason: "lab done" })).status).toBe(200); | |
| 102 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 103 | expect(capacityChanges(app.db)[0]!.reason).toBe("lab done"); | |
| 104 | expect((await put({ value: "report", reason: "lab again" })).status).toBe(200); | |
| 105 | expect(egressSetting(app.db)).toBe("report"); | |
| 106 | // Tightening never waits on a reason. | |
| 107 | expect((await put({ value: "enforce" })).status).toBe(200); | |
| 108 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 109 | ||
| 110 | // The page lists the reason beside who. | |
| 111 | const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${bearer}` } })).text(); | |
| 112 | expect(page).toContain("lab done"); | |
| 113 | expect(page).toContain('name="reason"'); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a setting that needs no reason still takes none", () => { | |
| 117 | expect(changeCapacity(app, alice, "checks.shards", "2").ok).toBe(true); | |
| 118 | expect(capacityChanges(app.db)[0]!.reason).toBe(""); | |
| 119 | changeCapacity(app, alice, "checks.shards", null); | |
| 120 | }); | |
| 121 | ||
| 122 | test("tightening needs no reason: enforce and the reset to default both go through bare", () => { | |
| 123 | expect(changeCapacity(app, alice, "cells.egress", "report", "a window").ok).toBe(true); | |
| 124 | expect(changeCapacity(app, alice, "cells.egress", "enforce").ok).toBe(true); | |
| 125 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 126 | expect(changeCapacity(app, alice, "cells.egress", "report", "a window").ok).toBe(true); | |
| 127 | expect(changeCapacity(app, alice, "cells.egress", null).ok).toBe(true); | |
| 128 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a reason is cut once, at the action, to REASON_MAX", () => { | |
| 132 | expect(changeCapacity(app, alice, "cells.egress", "report", "x".repeat(REASON_MAX + 50)).ok).toBe(true); | |
| 133 | expect(capacityChanges(app.db)[0]!.reason.length).toBe(REASON_MAX); | |
| 134 | }); | |
| 135 | ||
| 136 | test("the page: report without a reason is refused, with one it holds, and Put back needs none", async () => { | |
| 137 | const signin = await fetch(`${servers.url}/signin`, { | |
| 138 | method: "POST", | |
| 139 | redirect: "manual", | |
| 140 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 141 | body: new URLSearchParams({ username: "alice", password: "correct-horse-battery" }), | |
| 142 | }); | |
| 143 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 144 | const post = (body: Record<string, string>) => | |
| 145 | fetch(`${servers.url}/settings/capacity`, { | |
| 146 | method: "POST", | |
| 147 | redirect: "manual", | |
| 148 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 149 | body: new URLSearchParams(body), | |
| 150 | }); | |
| 151 | await post({ key: "cells.egress", value: "report" }); | |
| 152 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 153 | await post({ key: "cells.egress", value: "report", reason: "page says why" }); | |
| 154 | expect(egressSetting(app.db)).toBe("report"); | |
| 155 | expect(capacityChanges(app.db)[0]!.reason).toBe("page says why"); | |
| 156 | await post({ key: "cells.egress", reset: "1" }); | |
| 157 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 158 | // The reason is a labelled field inside the setting's own form, posted with Save. | |
| 159 | const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } })).text(); | |
| 160 | const form = [...page.matchAll(/<form\b[\s\S]*?<\/form>/g)] | |
| 161 | .map((m) => m[0]) | |
| 162 | .find((f) => f.includes('value="cells.egress"')); | |
| 163 | expect(form).toContain('name="reason"'); | |
| 164 | expect(form).toContain('aria-label="Why (needed to set report)"'); | |
| 165 | }); | |
| 166 | ||
| 167 | test("MCP: report needs the reason argument, and keeps it", async () => { | |
| 168 | const call = async (args: Record<string, unknown>) => { | |
| 169 | const res = await fetch(`${servers.url}/mcp`, { | |
| 170 | method: "POST", | |
| 171 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 172 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "capacity", arguments: args } }), | |
| 173 | }); | |
| 174 | const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 175 | return { error: body.result.isError === true, text: body.result.content[0]!.text }; | |
| 176 | }; | |
| 177 | const bare = await call({ key: "cells.egress", value: "report", dryRun: false }); | |
| 178 | expect(bare.error).toBe(true); | |
| 179 | expect(bare.text).toContain("reason_required"); | |
| 180 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 181 | expect((await call({ key: "cells.egress", value: "report", reason: "mcp says why", dryRun: false })).error).toBe(false); | |
| 182 | expect(capacityChanges(app.db)[0]!.reason).toBe("mcp says why"); | |
| 183 | expect((await call({ key: "cells.egress", value: "enforce", dryRun: false })).error).toBe(false); | |
| 184 | expect(egressSetting(app.db)).toBe("enforce"); | |
| 185 | }); |
test/tui-capacity.test.ts +32 −2
| @@ -11,7 +11,7 @@ import { createAgent } from "../src/actions/crew.ts"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { listEvents } from "../src/core/events.ts"; |
| 13 | 13 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 14 | import { CAPACITY_KEYS, capacityDefault, capacityValue } from "../src/core/capacity.ts"; | |
| 14 | import { CAPACITY_KEYS, capacityChanges, capacityDefault, capacityValue } from "../src/core/capacity.ts"; | |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; |
| 17 | 17 | import { capacityScreen } from "../src/tui/screens/capacity.ts"; |
| @@ -98,7 +98,7 @@ describe("tui capacity", () => { | ||
| 98 | 98 | }; |
| 99 | 99 | try { |
| 100 | 100 | expect(await egressShown()).toBe(false); |
| 101 | expect(changeCapacity(app, carol, "cells.egress", "report").ok).toBe(true); | |
| 101 | expect(changeCapacity(app, carol, "cells.egress", "report", "a test says why").ok).toBe(true); | |
| 102 | 102 | expect(await egressShown()).toBe(true); |
| 103 | 103 | expect(changeCapacity(app, carol, "cells.egress", "enforce").ok).toBe(true); |
| 104 | 104 | expect(await egressShown()).toBe(false); |
| @@ -107,6 +107,36 @@ describe("tui capacity", () => { | ||
| 107 | 107 | } |
| 108 | 108 | }); |
| 109 | 109 | |
| 110 | test("cells.egress to report asks why in the terminal, keeps it with who; enforce asks nothing (#10778)", async () => { | |
| 111 | const egress = () => capacityValue(app.db, "cells.egress"); | |
| 112 | const h = boot(carol, 60); | |
| 113 | try { | |
| 114 | for (let i = 0; i < CAPACITY_KEYS.indexOf("cells.egress"); i++) await h.press("j"); | |
| 115 | await h.press("e"); | |
| 116 | for (let i = 0; i < 10; i++) await h.press("backspace"); | |
| 117 | await h.press(..."report", "enter"); | |
| 118 | expect(h.text()).toContain("Why set cells.egress to report?"); | |
| 119 | await h.press("enter"); | |
| 120 | expect(h.text()).toContain("Say why"); | |
| 121 | await h.press(..."lab move", "enter"); | |
| 122 | expect(h.text()).toContain("why: lab move"); | |
| 123 | await h.press("left", "enter"); | |
| 124 | expect(egress()).toBe("report"); | |
| 125 | const kept = capacityChanges(app.db)[0]!; | |
| 126 | expect([kept.actorName, kept.reason]).toEqual(["carol", "lab move"]); | |
| 127 | ||
| 128 | await h.press("e"); | |
| 129 | for (let i = 0; i < 10; i++) await h.press("backspace"); | |
| 130 | await h.press(..."enforce", "enter"); | |
| 131 | expect(h.text()).not.toContain("Why set"); | |
| 132 | await h.press("left", "enter"); | |
| 133 | expect(egress()).toBe("enforce"); | |
| 134 | } finally { | |
| 135 | h.stop(); | |
| 136 | changeCapacity(app, carol, "cells.egress", null); | |
| 137 | } | |
| 138 | }); | |
| 139 | ||
| 110 | 140 | test("a crew concurrency below its ceiling is marked, and not at it", async () => { |
| 111 | 141 | const ceiling = capacityDefault("crew.concurrency"); |
| 112 | 142 | try { |
test/tui-system.test.ts +1 −1
| @@ -285,7 +285,7 @@ describe("tui system: the forge's own state (#7296)", () => { | ||
| 285 | 285 | await seed({}); |
| 286 | 286 | try { |
| 287 | 287 | expect(await systemText(carol)).not.toContain(EGRESS_REPORTED.slice(0, 40)); |
| 288 | expect(changeCapacity(app, carol, "cells.egress", "report").ok).toBe(true); | |
| 288 | expect(changeCapacity(app, carol, "cells.egress", "report", "a test says why").ok).toBe(true); | |
| 289 | 289 | const text = await systemText(carol); |
| 290 | 290 | expect(text).toContain("forge 9.8.7 · 0123456 · serving"); |
| 291 | 291 | // The dialog clips the sentence at its width; its opening words are one row. |
test/web.test.ts +1 −1
| @@ -455,7 +455,7 @@ describe("web pages end to end", () => { | ||
| 455 | 455 | const { changeCapacity } = await import("../src/actions/capacity.ts"); |
| 456 | 456 | const { getUserByName } = await import("../src/core/users.ts"); |
| 457 | 457 | const ada = getUserByName(app.db, "ada")!; |
| 458 | expect(changeCapacity(app, ada, "cells.egress", "report").ok).toBe(true); | |
| 458 | expect(changeCapacity(app, ada, "cells.egress", "report", "a test says why").ok).toBe(true); | |
| 459 | 459 | try { |
| 460 | 460 | const text = await (await fetch(base, { headers: { cookie } })).text(); |
| 461 | 461 | expect(text).not.toMatch(/class="[^"]*\berror-note/); |
Only a change to report should need a reason. As written, enforce and the reset to default are refused without one, which blocks the safe direction (and the page's Put back). Fix:
needsReason(key) && next === "report", then test reset and enforce without a reason.