Public: anyone can read this; the crew can change it.
The check the crew must pass does not pass on main in a crew sandbox #239
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/233-the-check-the-crew-must-pass-does-not-pa → main 3 commits fork
37 files
changed +357
−168
CLAUDE.md +12
| @@ -62,6 +62,18 @@ bun run src/main.ts # boots on :3000, data in ./data | ||
| 62 | 62 | - **`git push` is blocked at the shell level.** In tests and scripts that need a |
| 63 | 63 | git _client_, drive it through `Bun.spawn(["git", ...])`, never a shell push. |
| 64 | 64 | See `test/app.test.ts` / `test/onboarding.test.ts` for the pattern. |
| 65 | - **A test's git child gets a built environment, never the host's.** Use | |
| 66 | `gitClientEnv` (`test/helpers/git-client.ts`) — `...process.env` carries | |
| 67 | `GIT_CONFIG_COUNT` (git configuration no `GIT_CONFIG_GLOBAL=/dev/null` | |
| 68 | turns off, and on a crew machine it holds that agent's forge credential) | |
| 69 | into a child pointed at a forge that never issued it. Same rule as | |
| 70 | `src/git/env.ts` server-side. | |
| 71 | - **A test that needs a binary this machine may lack skips, and says so.** | |
| 72 | `describe.skipIf(missing("/usr/bin/ssh"))` (`test/helpers/tools.ts`) — | |
| 73 | conditional on the tool, so nothing is lost where the tool is there, and | |
| 74 | the reason is printed so a skip is never read as a pass. | |
| 75 | - **`bun run check` is the gate**, and it reads its command from | |
| 76 | `forge.json`, so what you see is exactly what the push will be judged by. | |
| 65 | 77 | - **Every non-trivial change ships with a test.** Prefer protocol-level tests |
| 66 | 78 | that boot a real app (`createApp` + `serve`) over unit mocks. Adversarial |
| 67 | 79 | tests (can this be bypassed?) are worth more than happy-path ones. |
README.md +15
| @@ -298,8 +298,23 @@ tell, a run and a change each on its own page, and the board. | ||
| 298 | 298 | sh |
| 299 | 299 | bun test # protocol-level + adversarial suite |
| 300 | 300 | bun x tsc --noEmit # zero type errors is the standard |
| 301 | bun run check # exactly what the forge runs against every push | |
| 301 | 302 | |
| 302 | 303 | |
| 304 | `bun run check` is the gate: the same command `forge.json` declares, so what | |
| 305 | it says here is what the deploy will say. Two things it will not do: | |
| 306 | ||
| 307 | - **It never borrows your git configuration.** A test drives a real `git` | |
| 308 | against a forge it booted a second ago, and builds that child's | |
| 309 | environment rather than inheriting yours (`test/helpers/git-client.ts`) — | |
| 310 | otherwise a `GIT_CONFIG_COUNT` in your shell (a crew run sets one, holding | |
| 311 | its own forge credential) would send that credential to the test's server | |
| 312 | and every push in the suite would come back `401`. | |
| 313 | - **It skips what this machine cannot run, out loud.** The OpenSSH interop | |
| 314 | tests need `ssh` and `ssh-keygen`; without them the log says `SKIPPING:` | |
| 315 | and names the tool (`test/helpers/tools.ts`). Install OpenSSH and they run | |
| 316 | again — the skip is conditional on the binary, never on anything else. | |
| 317 | ||
| 303 | 318 | ## Shape |
| 304 | 319 | |
| 305 | 320 |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun test --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/repos.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/web.test.ts test/git-trust.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun test --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/repos.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
package.json +2 −1
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | "dev": "bun run --watch src/main.ts", |
| 8 | 8 | "start": "bun run src/main.ts", |
| 9 | 9 | "test": "bun test", |
| 10 | "typecheck": "bun x tsc --noEmit" | |
| 10 | "typecheck": "bun x tsc --noEmit", | |
| 11 | "check": "bun run scripts/check.ts" | |
| 11 | 12 | }, |
| 12 | 13 | "devDependencies": { |
| 13 | 14 | "@types/bun": "^1.2.0", |
scripts/check.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | /** | |
| 2 | * The gate, run the way the forge runs it. | |
| 3 | * | |
| 4 | * `forge.json`'s `check.run` is the command every push to a pull must pass. | |
| 5 | * A crew member has to be able to see it green before pushing, and the only | |
| 6 | * way that is true is if there is exactly one copy of the command: this | |
| 7 | * reads it from the manifest and hands it to `/bin/sh -c`, which is what | |
| 8 | * `apps/process.ts` does with an app's check on the deploy side. | |
| 9 | */ | |
| 10 | ||
| 11 | const manifest = (await Bun.file( | |
| 12 | new URL("../forge.json", import.meta.url), | |
| 13 | ).json()) as { check?: { run?: string } }; | |
| 14 | ||
| 15 | const command = manifest.check?.run; | |
| 16 | if (typeof command !== "string" || command.trim() === "") { | |
| 17 | console.error("forge.json declares no check.run"); | |
| 18 | process.exit(2); | |
| 19 | } | |
| 20 | ||
| 21 | const proc = Bun.spawn(["/bin/sh", "-c", command], { | |
| 22 | cwd: new URL("..", import.meta.url).pathname, | |
| 23 | stdio: ["inherit", "inherit", "inherit"], | |
| 24 | }); | |
| 25 | process.exit(await proc.exited); |
test/app.test.ts +3 −6
| @@ -4,17 +4,14 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 7 | 8 | |
| 8 | const clientEnv = { | |
| 9 | ...process.env, | |
| 9 | const clientEnv = gitClientEnv({ | |
| 10 | 10 | GIT_AUTHOR_NAME: "Alice", |
| 11 | 11 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 12 | 12 | GIT_COMMITTER_NAME: "Alice", |
| 13 | 13 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 14 | GIT_TERMINAL_PROMPT: "0", | |
| 15 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 16 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 17 | }; | |
| 14 | }); | |
| 18 | 15 | |
| 19 | 16 | async function sh( |
| 20 | 17 | args: string[], |
test/apps-adversarial.test.ts +3 −6
| @@ -64,19 +64,16 @@ import { | ||
| 64 | 64 | subscribeRunLog, |
| 65 | 65 | } from "../src/runs/log.ts"; |
| 66 | 66 | import { grantedCookie } from "./helpers/identity.ts"; |
| 67 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 67 | 68 | |
| 68 | 69 | const DOMAIN = "apps.test"; |
| 69 | 70 | |
| 70 | const clientEnv = { | |
| 71 | ...process.env, | |
| 71 | const clientEnv = gitClientEnv({ | |
| 72 | 72 | GIT_AUTHOR_NAME: "Agent", |
| 73 | 73 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 74 | 74 | GIT_COMMITTER_NAME: "Agent", |
| 75 | 75 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 76 | GIT_TERMINAL_PROMPT: "0", | |
| 77 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 78 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 79 | }; | |
| 76 | }); | |
| 80 | 77 | |
| 81 | 78 | async function sh(args: string[], cwd: string) { |
| 82 | 79 | const proc = Bun.spawn(args, { |
test/apps-ssh.test.ts +4 −1
| @@ -27,11 +27,14 @@ import { headlessTerminal } from "../src/tui/term/terminal.ts"; | ||
| 27 | 27 | import { getAppBySlug } from "../src/core/apps.ts"; |
| 28 | 28 | import { grantedCookie } from "./helpers/identity.ts"; |
| 29 | 29 | import { settleApp } from "./helpers/tui.ts"; |
| 30 | import { missing } from "./helpers/tools.ts"; | |
| 30 | 31 | |
| 31 | 32 | const SSH = "/usr/bin/ssh"; |
| 32 | 33 | const SSH_KEYGEN = "/usr/bin/ssh-keygen"; |
| 34 | /** These drive the real OpenSSH client; without it they skip, loudly. */ | |
| 35 | const NO_OPENSSH = missing(SSH, SSH_KEYGEN); | |
| 33 | 36 | |
| 34 | describe("a terminal for every app, over ssh", () => { | |
| 37 | describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => { | |
| 35 | 38 | let dataDir: string; |
| 36 | 39 | let app: App; |
| 37 | 40 | let servers: Servers; |
test/apps.test.ts +3 −6
| @@ -46,17 +46,14 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 46 | 46 | import { serve, type Servers } from "../src/http/server.ts"; |
| 47 | 47 | import { readRunLog } from "../src/runs/log.ts"; |
| 48 | 48 | import { grantedCookie } from "./helpers/identity.ts"; |
| 49 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 49 | 50 | |
| 50 | const clientEnv = { | |
| 51 | ...process.env, | |
| 51 | const clientEnv = gitClientEnv({ | |
| 52 | 52 | GIT_AUTHOR_NAME: "Agent", |
| 53 | 53 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 54 | 54 | GIT_COMMITTER_NAME: "Agent", |
| 55 | 55 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 56 | GIT_TERMINAL_PROMPT: "0", | |
| 57 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 58 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 59 | }; | |
| 56 | }); | |
| 60 | 57 | |
| 61 | 58 | async function sh(args: string[], cwd: string) { |
| 62 | 59 | const proc = Bun.spawn(args, { |
test/cli-sync.test.ts +4 −4
| @@ -17,21 +17,21 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | import { hostDir } from "../src/cli/config.ts"; |
| 19 | 19 | import { changesBetween, type RepoReport, type SyncReport } from "../src/cli/sync.ts"; |
| 20 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 20 | 21 | |
| 21 | 22 | const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname; |
| 22 | 23 | |
| 23 | const clientEnv = { | |
| 24 | const clientEnv = gitClientEnv({ | |
| 24 | 25 | GIT_AUTHOR_NAME: "Alice", |
| 25 | 26 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 26 | 27 | GIT_COMMITTER_NAME: "Alice", |
| 27 | 28 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 28 | GIT_TERMINAL_PROMPT: "0", | |
| 29 | }; | |
| 29 | }); | |
| 30 | 30 | |
| 31 | 31 | async function sh(args: string[], cwd: string): Promise<string> { |
| 32 | 32 | const proc = Bun.spawn(args, { |
| 33 | 33 | cwd, |
| 34 | env: { ...process.env, ...clientEnv }, | |
| 34 | env: clientEnv, | |
| 35 | 35 | stdout: "pipe", |
| 36 | 36 | stderr: "pipe", |
| 37 | 37 | }); |
test/cross-repo-pull.test.ts +3 −6
| @@ -4,17 +4,14 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 7 | 8 | |
| 8 | const gitEnv = { | |
| 9 | ...process.env, | |
| 9 | const gitEnv = gitClientEnv({ | |
| 10 | 10 | GIT_AUTHOR_NAME: "X", |
| 11 | 11 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 12 | 12 | GIT_COMMITTER_NAME: "X", |
| 13 | 13 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 14 | GIT_TERMINAL_PROMPT: "0", | |
| 15 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 16 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 17 | }; | |
| 14 | }); | |
| 18 | 15 | |
| 19 | 16 | describe("cross-repo pull requests (fork → upstream)", () => { |
| 20 | 17 | let dataDir: string; |
test/diff-edge-cases.test.ts +3 −6
| @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 11 | 12 | |
| 12 | 13 | describe("diff engine + resource edge cases", () => { |
| 13 | 14 | let dataDir: string; |
| @@ -18,16 +19,12 @@ describe("diff engine + resource edge cases", () => { | ||
| 18 | 19 | let cookie = ""; |
| 19 | 20 | let token = ""; |
| 20 | 21 | |
| 21 | const clientEnv = { | |
| 22 | ...process.env, | |
| 22 | const clientEnv = gitClientEnv({ | |
| 23 | 23 | GIT_AUTHOR_NAME: "Ada", |
| 24 | 24 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 25 | 25 | GIT_COMMITTER_NAME: "Ada", |
| 26 | 26 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 27 | GIT_TERMINAL_PROMPT: "0", | |
| 28 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 29 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 30 | }; | |
| 27 | }); | |
| 31 | 28 | |
| 32 | 29 | async function shOk(args: string[], cwd: string): Promise<string> { |
| 33 | 30 | const proc = Bun.spawn(args, { |
test/diff.test.ts +3 −6
| @@ -21,17 +21,14 @@ import { | ||
| 21 | 21 | stubFor, |
| 22 | 22 | type DiffViewOptions, |
| 23 | 23 | } from "../src/web/diff-view.ts"; |
| 24 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 24 | 25 | |
| 25 | const env = { | |
| 26 | ...process.env, | |
| 26 | const env = gitClientEnv({ | |
| 27 | 27 | GIT_AUTHOR_NAME: "D", |
| 28 | 28 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 29 | 29 | GIT_COMMITTER_NAME: "D", |
| 30 | 30 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 31 | GIT_TERMINAL_PROMPT: "0", | |
| 32 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 33 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 34 | }; | |
| 31 | }); | |
| 35 | 32 | |
| 36 | 33 | async function sh(args: string[], cwd: string): Promise<string> { |
| 37 | 34 | const p = Bun.spawn(args, { cwd, env, stdout: "pipe", stderr: "pipe" }); |
test/forks.test.ts +3 −10
| @@ -6,21 +6,14 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { deleteRepo, getRepo } from "../src/core/repos.ts"; |
| 7 | 7 | import { repoDir } from "../src/git/repos.ts"; |
| 8 | 8 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 9 | 10 | |
| 10 | const gitEnv = { | |
| 11 | ...process.env, | |
| 11 | const gitEnv = gitClientEnv({ | |
| 12 | 12 | GIT_AUTHOR_NAME: "F", |
| 13 | 13 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 14 | 14 | GIT_COMMITTER_NAME: "F", |
| 15 | 15 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 16 | GIT_TERMINAL_PROMPT: "0", | |
| 17 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 18 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 19 | // The same reason the two above are neutered: an ambient GIT_CONFIG_COUNT | |
| 20 | // (a crew run hands git an `http.extraHeader` for *its* forge) would send | |
| 21 | // someone else's credential to the test server instead of the URL's. | |
| 22 | GIT_CONFIG_COUNT: "0", | |
| 23 | }; | |
| 16 | }); | |
| 24 | 17 | |
| 25 | 18 | describe("forks", () => { |
| 26 | 19 | let dataDir: string; |
test/git-local.test.ts +17 −1
| @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; | ||
| 13 | 13 | import { join } from "node:path"; |
| 14 | 14 | import * as git from "../src/git/local.ts"; |
| 15 | 15 | import { unwrap } from "../src/kernel/result.ts"; |
| 16 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 16 | 17 | |
| 17 | 18 | const IDENTITY = { |
| 18 | 19 | GIT_AUTHOR_NAME: "Ada", |
| @@ -25,7 +26,7 @@ const IDENTITY = { | ||
| 25 | 26 | async function sh(args: string[], cwd: string): Promise<string> { |
| 26 | 27 | const proc = Bun.spawn(["git", ...args], { |
| 27 | 28 | cwd, |
| 28 | env: { ...process.env, ...IDENTITY, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0" }, | |
| 29 | env: gitClientEnv(IDENTITY), | |
| 29 | 30 | stdout: "pipe", |
| 30 | 31 | stderr: "pipe", |
| 31 | 32 | }); |
| @@ -47,12 +48,27 @@ async function newRepo(root: string, name: string): Promise<string> { | ||
| 47 | 48 | } |
| 48 | 49 | |
| 49 | 50 | let root = ""; |
| 51 | const ambient: Record<string, string | undefined> = {}; | |
| 50 | 52 | |
| 51 | 53 | beforeAll(async () => { |
| 52 | 54 | root = await mkdtemp(join(tmpdir(), "forge-local-")); |
| 55 | // `git/local.ts` acts as the person at the keyboard: it inherits their | |
| 56 | // environment on purpose (their identity, their credential helpers — see | |
| 57 | // "the environment", below), so a commit *it* writes is authored by | |
| 58 | // whoever this machine says you are. Here that person is Ada, and a | |
| 59 | // machine whose shell already names an author (a crew run names its | |
| 60 | // agent) must not decide it instead. | |
| 61 | for (const [key, value] of Object.entries(IDENTITY)) { | |
| 62 | ambient[key] = process.env[key]; | |
| 63 | process.env[key] = value; | |
| 64 | } | |
| 53 | 65 | }); |
| 54 | 66 | |
| 55 | 67 | afterAll(async () => { |
| 68 | for (const [key, value] of Object.entries(ambient)) { | |
| 69 | if (value === undefined) delete process.env[key]; | |
| 70 | else process.env[key] = value; | |
| 71 | } | |
| 56 | 72 | await rm(root, { recursive: true, force: true }); |
| 57 | 73 | }); |
| 58 | 74 |
test/git-ssh.test.ts +14 −13
| @@ -32,10 +32,14 @@ import { runGitService, type GitChannel } from "../src/git/smart-ssh.ts"; | ||
| 32 | 32 | import { serveForgeSsh } from "../src/tui/ssh.ts"; |
| 33 | 33 | import type { SshServer } from "../src/ssh/server.ts"; |
| 34 | 34 | import { serve, type Servers } from "../src/http/server.ts"; |
| 35 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 36 | import { missing } from "./helpers/tools.ts"; | |
| 35 | 37 | |
| 36 | 38 | setLogLevel("error"); |
| 37 | 39 | |
| 38 | 40 | const SSH_KEYGEN = "/usr/bin/ssh-keygen"; |
| 41 | /** The client half is the real OpenSSH; without it these skip, loudly. */ | |
| 42 | const NO_OPENSSH = missing("/usr/bin/ssh", SSH_KEYGEN); | |
| 39 | 43 | |
| 40 | 44 | interface Run { |
| 41 | 45 | code: number; |
| @@ -43,7 +47,7 @@ interface Run { | ||
| 43 | 47 | err: string; |
| 44 | 48 | } |
| 45 | 49 | |
| 46 | describe("git over ssh", () => { | |
| 50 | describe.skipIf(NO_OPENSSH)("git over ssh", () => { | |
| 47 | 51 | let dataDir: string; |
| 48 | 52 | let keyDir: string; |
| 49 | 53 | let work: string; |
| @@ -53,17 +57,14 @@ describe("git over ssh", () => { | ||
| 53 | 57 | let alice: User; |
| 54 | 58 | let bob: User; |
| 55 | 59 | |
| 56 | const env = (key: string): Record<string, string> => ({ | |
| 57 | ...(process.env as Record<string, string>), | |
| 58 | GIT_SSH_COMMAND: `/usr/bin/ssh -i ${key} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=5`, | |
| 59 | GIT_AUTHOR_NAME: "Tester", | |
| 60 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 61 | GIT_COMMITTER_NAME: "Tester", | |
| 62 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 63 | GIT_TERMINAL_PROMPT: "0", | |
| 64 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 65 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 66 | }); | |
| 60 | const env = (key: string): Record<string, string> => | |
| 61 | gitClientEnv({ | |
| 62 | GIT_SSH_COMMAND: `/usr/bin/ssh -i ${key} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=5`, | |
| 63 | GIT_AUTHOR_NAME: "Tester", | |
| 64 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 65 | GIT_COMMITTER_NAME: "Tester", | |
| 66 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 67 | }); | |
| 67 | 68 | |
| 68 | 69 | /** `git push` is blocked at the shell level, so every client call spawns. */ |
| 69 | 70 | async function git( |
| @@ -310,7 +311,7 @@ describe("git over ssh", () => { | ||
| 310 | 311 | * real ssh; what this isolates is the lifetime rule those tests cannot make |
| 311 | 312 | * visible — whether the child outlives a peer that walked away. |
| 312 | 313 | */ |
| 313 | describe("a git service outlives neither the channel nor the peer", () => { | |
| 314 | describe.skipIf(NO_OPENSSH)("a git service outlives neither the channel nor the peer", () => { | |
| 314 | 315 | let dataDir: string; |
| 315 | 316 | let forge: Forge; |
| 316 | 317 | let dir: string; |
test/git.test.ts +3 −6
| @@ -27,18 +27,15 @@ import { | ||
| 27 | 27 | serviceRpc, |
| 28 | 28 | } from "../src/git/smart-http.ts"; |
| 29 | 29 | import { unwrap } from "../src/kernel/result.ts"; |
| 30 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 30 | 31 | |
| 31 | 32 | // Client-side git for driving the server under test. |
| 32 | const clientEnv = { | |
| 33 | ...process.env, | |
| 33 | const clientEnv = gitClientEnv({ | |
| 34 | 34 | GIT_AUTHOR_NAME: "Test", |
| 35 | 35 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 36 | 36 | GIT_COMMITTER_NAME: "Test", |
| 37 | 37 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 38 | GIT_TERMINAL_PROMPT: "0", | |
| 39 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 40 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 41 | }; | |
| 38 | }); | |
| 42 | 39 | |
| 43 | 40 | async function sh(args: string[], cwd: string): Promise<string> { |
| 44 | 41 | const proc = Bun.spawn(args, { |
test/hardening.test.ts +3 −6
| @@ -15,17 +15,14 @@ import { reconcile } from "../src/core/reconcile.ts"; | ||
| 15 | 15 | import { repoDir } from "../src/git/repos.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 17 | import { renderMarkdown } from "../src/web/markdown.ts"; |
| 18 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 18 | 19 | |
| 19 | const gitEnv = { | |
| 20 | ...process.env, | |
| 20 | const gitEnv = gitClientEnv({ | |
| 21 | 21 | GIT_AUTHOR_NAME: "T", |
| 22 | 22 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 23 | 23 | GIT_COMMITTER_NAME: "T", |
| 24 | 24 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 25 | GIT_TERMINAL_PROMPT: "0", | |
| 26 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 27 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 28 | }; | |
| 25 | }); | |
| 29 | 26 | |
| 30 | 27 | async function shOk(args: string[], cwd: string): Promise<void> { |
| 31 | 28 | const proc = Bun.spawn(args, { |
test/helpers.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * The two helpers the suite's own health rests on: the environment a git | |
| 3 | * client under test runs in, and whether a tool this machine would need is | |
| 4 | * here. Both exist because of a run that failed for the machine's reasons | |
| 5 | * and was read as the change's fault (#233). | |
| 6 | */ | |
| 7 | ||
| 8 | import { 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 { gitClientEnv } from "./helpers/git-client.ts"; | |
| 13 | import { missing } from "./helpers/tools.ts"; | |
| 14 | ||
| 15 | describe("a git client's environment under test", () => { | |
| 16 | test("carries no ambient git configuration, whatever the host sets", () => { | |
| 17 | const env = gitClientEnv(); | |
| 18 | // The crew's own shell sets exactly this: configuration in variables, | |
| 19 | // which `GIT_CONFIG_GLOBAL=/dev/null` cannot switch off, carrying an | |
| 20 | // Authorization header for *its* forge. None of it comes through. | |
| 21 | expect(env["GIT_CONFIG_COUNT"]).toBeUndefined(); | |
| 22 | expect(env["GIT_CONFIG_KEY_0"]).toBeUndefined(); | |
| 23 | expect(env["GIT_CONFIG_VALUE_0"]).toBeUndefined(); | |
| 24 | expect(env["GIT_CONFIG_PARAMETERS"]).toBeUndefined(); | |
| 25 | expect(env["GIT_CONFIG_GLOBAL"]).toBe("/dev/null"); | |
| 26 | expect(env["GIT_CONFIG_SYSTEM"]).toBe("/dev/null"); | |
| 27 | expect(env["GIT_TERMINAL_PROMPT"]).toBe("0"); | |
| 28 | expect(env["PATH"]).toBe(process.env["PATH"] ?? "/usr/bin:/bin"); | |
| 29 | // The test's own additions win. | |
| 30 | expect(gitClientEnv({ GIT_AUTHOR_NAME: "Ada" })["GIT_AUTHOR_NAME"]).toBe( | |
| 31 | "Ada", | |
| 32 | ); | |
| 33 | }); | |
| 34 | ||
| 35 | test("a push to a URL that names its credentials is the one git sends", async () => { | |
| 36 | // The end of the story above, without a server: a config header set the | |
| 37 | // way the crew's shell sets it would reach the remote instead of the | |
| 38 | // URL's credentials. `git config --list` is what git would obey. | |
| 39 | const dir = await mkdtemp(join(tmpdir(), "forge-git-env-")); | |
| 40 | try { | |
| 41 | const listed = async (env: Record<string, string>): Promise<string> => { | |
| 42 | const proc = Bun.spawn(["git", "config", "--list"], { | |
| 43 | cwd: dir, | |
| 44 | env, | |
| 45 | stdout: "pipe", | |
| 46 | stderr: "ignore", | |
| 47 | }); | |
| 48 | const [out] = await Promise.all([ | |
| 49 | new Response(proc.stdout).text(), | |
| 50 | proc.exited, | |
| 51 | ]); | |
| 52 | return out; | |
| 53 | }; | |
| 54 | const ambient = { | |
| 55 | ...gitClientEnv(), | |
| 56 | GIT_CONFIG_COUNT: "1", | |
| 57 | GIT_CONFIG_KEY_0: "http.extraHeader", | |
| 58 | GIT_CONFIG_VALUE_0: "Authorization: Basic c29tZWJvZHk6ZWxzZQ==", | |
| 59 | }; | |
| 60 | expect(await listed(ambient)).toContain("http.extraheader="); | |
| 61 | expect(await listed(gitClientEnv())).not.toContain("http.extraheader="); | |
| 62 | } finally { | |
| 63 | await rm(dir, { recursive: true, force: true }); | |
| 64 | } | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("whether a tool is on this machine", () => { | |
| 69 | test("a tool that is here is never skipped; one that is not, is", () => { | |
| 70 | expect(missing("/bin/sh")).toBe(false); | |
| 71 | expect(missing("sh")).toBe(false); | |
| 72 | expect(missing("no-such-tool-on-any-machine")).toBe(true); | |
| 73 | expect(missing("/usr/bin/no-such-tool")).toBe(true); | |
| 74 | // Any one absent is enough to skip. | |
| 75 | expect(missing("sh", "no-such-tool-on-any-machine")).toBe(true); | |
| 76 | }); | |
| 77 | }); |
test/helpers/git-client.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * The environment a test's `git` *client* runs in. | |
| 3 | * | |
| 4 | * Server-side git already refuses to inherit the host (`src/git/env.ts`: | |
| 5 | * "Nothing from the host leaks in"). A test drives a real git binary against | |
| 6 | * a forge it booted a second ago, so it needs the same discipline, and for a | |
| 7 | * sharper reason: git reads configuration from the environment as well as | |
| 8 | * from files. `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` | |
| 9 | * is configuration no `GIT_CONFIG_GLOBAL=/dev/null` can switch off. | |
| 10 | * | |
| 11 | * On a crew machine that pair is set: the agent's shell carries | |
| 12 | * `http.extraHeader=Authorization: Basic <the agent's forge token>` so its | |
| 13 | * own pushes authenticate. A test that spreads `process.env` into its git | |
| 14 | * child sends that header to *its* forge, where the credential means | |
| 15 | * nothing — and an explicit `Authorization` header wins over the | |
| 16 | * `user:token` in the clone URL, so the push comes back `401 auth required` | |
| 17 | * and the test fails for reasons that have nothing to do with the code. | |
| 18 | * | |
| 19 | * So: build the child environment, never inherit it. | |
| 20 | */ | |
| 21 | ||
| 22 | /** Author/committer identity every test commit is made with. */ | |
| 23 | const IDENTITY = { | |
| 24 | GIT_AUTHOR_NAME: "Dev", | |
| 25 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 26 | GIT_COMMITTER_NAME: "Dev", | |
| 27 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 28 | } as const; | |
| 29 | ||
| 30 | /** | |
| 31 | * A deterministic environment for a git client under test: `PATH` and | |
| 32 | * nothing else from the host, no global or system config, no prompt, and a | |
| 33 | * fixed identity. `extra` is the test's own additions — a clone URL's | |
| 34 | * askpass, `GIT_SSH_COMMAND`, a different author — and wins. | |
| 35 | */ | |
| 36 | export function gitClientEnv( | |
| 37 | extra: Record<string, string> = {}, | |
| 38 | ): Record<string, string> { | |
| 39 | return { | |
| 40 | PATH: process.env["PATH"] ?? "/usr/bin:/bin", | |
| 41 | HOME: "/dev/null", | |
| 42 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 43 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 44 | GIT_TERMINAL_PROMPT: "0", | |
| 45 | LC_ALL: "C", | |
| 46 | ...IDENTITY, | |
| 47 | ...extra, | |
| 48 | }; | |
| 49 | } |
test/helpers/tools.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * Is this tool on this machine? | |
| 3 | * | |
| 4 | * A few test files drive a real binary the forge itself never needs — | |
| 5 | * OpenSSH's `ssh` and `ssh-keygen`, to prove our SSH server interoperates | |
| 6 | * with the client people actually use. A machine without them cannot run | |
| 7 | * those tests, and a test that cannot run should say so and skip: failing | |
| 8 | * is a lie about the code, and skipping quietly is a lie about the run. | |
| 9 | * | |
| 10 | * So `missing` is a condition for `describe.skipIf`/`test.skipIf`, and it | |
| 11 | * prints the reason once per tool. Never skip unconditionally: on a machine | |
| 12 | * that has the tool, every one of these tests still runs. | |
| 13 | */ | |
| 14 | ||
| 15 | import { existsSync } from "node:fs"; | |
| 16 | ||
| 17 | /** A bare name is looked up on PATH; an absolute path is looked at. */ | |
| 18 | function here(tool: string): boolean { | |
| 19 | return tool.startsWith("/") ? existsSync(tool) : Bun.which(tool) !== null; | |
| 20 | } | |
| 21 | ||
| 22 | const announced = new Set<string>(); | |
| 23 | ||
| 24 | /** | |
| 25 | * True when any of `tools` is absent — and says which, on stdout, so the | |
| 26 | * skip is visible in the log and nobody reads it as a pass. | |
| 27 | */ | |
| 28 | export function missing(...tools: string[]): boolean { | |
| 29 | const absent = tools.filter((tool) => !here(tool)); | |
| 30 | for (const tool of absent) { | |
| 31 | if (announced.has(tool)) continue; | |
| 32 | announced.add(tool); | |
| 33 | console.log( | |
| 34 | `SKIPPING: ${tool} is not installed here, so the tests that drive it cannot run on this machine`, | |
| 35 | ); | |
| 36 | } | |
| 37 | return absent.length > 0; | |
| 38 | } |
test/keys.test.ts +5 −1
| @@ -19,6 +19,10 @@ import { | ||
| 19 | 19 | } from "../src/core/keys.ts"; |
| 20 | 20 | import type { User } from "../src/core/users.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | import { missing } from "./helpers/tools.ts"; | |
| 23 | ||
| 24 | /** Real keys come from real ssh-keygen; without it these skip, loudly. */ | |
| 25 | const NO_KEYGEN = missing("ssh-keygen"); | |
| 22 | 26 | |
| 23 | 27 | async function keygen(dir: string, name: string, args: string[]) { |
| 24 | 28 | const file = join(dir, name); |
| @@ -34,7 +38,7 @@ async function keygen(dir: string, name: string, args: string[]) { | ||
| 34 | 38 | return (await readFile(`${file}.pub`, "utf8")).trim(); |
| 35 | 39 | } |
| 36 | 40 | |
| 37 | describe("ssh keys", () => { | |
| 41 | describe.skipIf(NO_KEYGEN)("ssh keys", () => { | |
| 38 | 42 | let dataDir: string; |
| 39 | 43 | let keyDir: string; |
| 40 | 44 | let app: App; |
test/onboarding.test.ts +3 −6
| @@ -4,17 +4,14 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join, relative } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 7 | 8 | |
| 8 | const gitEnv = { | |
| 9 | ...process.env, | |
| 10 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 11 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 12 | GIT_TERMINAL_PROMPT: "0", | |
| 9 | const gitEnv = gitClientEnv({ | |
| 13 | 10 | GIT_AUTHOR_NAME: "N", |
| 14 | 11 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 15 | 12 | GIT_COMMITTER_NAME: "N", |
| 16 | 13 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 17 | }; | |
| 14 | }); | |
| 18 | 15 | |
| 19 | 16 | describe("onboarding: a person with just a login", () => { |
| 20 | 17 | let dataDir: string; |
test/orgs.test.ts +3 −6
| @@ -18,17 +18,14 @@ import { canRead, canWrite, canAdmin } from "../src/core/repos.ts"; | ||
| 18 | 18 | import { createRepo, getRepo } from "../src/core/repos.ts"; |
| 19 | 19 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 20 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 21 | 22 | |
| 22 | const gitEnv = { | |
| 23 | ...process.env, | |
| 23 | const gitEnv = gitClientEnv({ | |
| 24 | 24 | GIT_AUTHOR_NAME: "O", |
| 25 | 25 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 26 | 26 | GIT_COMMITTER_NAME: "O", |
| 27 | 27 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 28 | GIT_TERMINAL_PROMPT: "0", | |
| 29 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 30 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 31 | }; | |
| 28 | }); | |
| 32 | 29 | |
| 33 | 30 | describe("org & team core", () => { |
| 34 | 31 | let dataDir: string; |
test/origins-security-push.test.ts +3 −6
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | serveFakeForge, |
| 26 | 26 | type FakeForge, |
| 27 | 27 | } from "./fake-origin.ts"; |
| 28 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 28 | 29 | |
| 29 | 30 | /** |
| 30 | 31 | * Adversarial push-through. The pusher is the attacker: a local writer on a |
| @@ -63,17 +64,13 @@ const read = async <T>(response: Response): Promise<T> => | ||
| 63 | 64 | // Captured before the PATH wrapper goes in, so the *client* git in these |
| 64 | 65 | // tests is the real one and only the forge's spawns are recorded. |
| 65 | 66 | const ORIGINAL_PATH = process.env.PATH ?? "/usr/bin:/bin"; |
| 66 | const clientEnv = { | |
| 67 | ...process.env, | |
| 67 | const clientEnv = gitClientEnv({ | |
| 68 | 68 | PATH: ORIGINAL_PATH, |
| 69 | 69 | GIT_AUTHOR_NAME: "Alice", |
| 70 | 70 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 71 | 71 | GIT_COMMITTER_NAME: "Alice", |
| 72 | 72 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 73 | GIT_TERMINAL_PROMPT: "0", | |
| 74 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 75 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 76 | }; | |
| 73 | }); | |
| 77 | 74 | |
| 78 | 75 | async function git( |
| 79 | 76 | args: string[], |
test/parity.test.ts +3 −6
| @@ -15,17 +15,14 @@ import { listEvents } from "../src/core/events.ts"; | ||
| 15 | 15 | import { createSession, pruneSessions } from "../src/core/sessions.ts"; |
| 16 | 16 | import { getRepo } from "../src/core/repos.ts"; |
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 18 | 19 | |
| 19 | const gitEnv = { | |
| 20 | ...process.env, | |
| 20 | const gitEnv = gitClientEnv({ | |
| 21 | 21 | GIT_AUTHOR_NAME: "A", |
| 22 | 22 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 23 | 23 | GIT_COMMITTER_NAME: "A", |
| 24 | 24 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 25 | GIT_TERMINAL_PROMPT: "0", | |
| 26 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 27 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 28 | }; | |
| 25 | }); | |
| 29 | 26 | |
| 30 | 27 | async function git(args: string[], cwd: string): Promise<string> { |
| 31 | 28 | const p = Bun.spawn(["git", ...args], { |
test/pulls.test.ts +2 −10
| @@ -4,17 +4,9 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 7 | 8 | |
| 8 | const gitEnv = { | |
| 9 | ...process.env, | |
| 10 | GIT_AUTHOR_NAME: "Dev", | |
| 11 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 12 | GIT_COMMITTER_NAME: "Dev", | |
| 13 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 14 | GIT_TERMINAL_PROMPT: "0", | |
| 15 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 16 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 17 | }; | |
| 9 | const gitEnv = gitClientEnv(); | |
| 18 | 10 | |
| 19 | 11 | describe("pull requests end to end", () => { |
| 20 | 12 | let dataDir: string; |
test/push-through.test.ts +3 −6
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | serveFakeForge, |
| 13 | 13 | type FakeForge, |
| 14 | 14 | } from "./fake-origin.ts"; |
| 15 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * Push-through: a `git push` to a mirror lands upstream, or it does not land |
| @@ -30,16 +31,12 @@ const basic = (user: string, secret: string): string => | ||
| 30 | 31 | const read = async <T>(response: Response): Promise<T> => |
| 31 | 32 | (await response.json()) as T; |
| 32 | 33 | |
| 33 | const clientEnv = { | |
| 34 | ...process.env, | |
| 34 | const clientEnv = gitClientEnv({ | |
| 35 | 35 | GIT_AUTHOR_NAME: "Alice", |
| 36 | 36 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 37 | 37 | GIT_COMMITTER_NAME: "Alice", |
| 38 | 38 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 39 | GIT_TERMINAL_PROMPT: "0", | |
| 40 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 41 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 42 | }; | |
| 39 | }); | |
| 43 | 40 | |
| 44 | 41 | async function git( |
| 45 | 42 | args: string[], |
test/reviews.test.ts +3 −6
| @@ -5,17 +5,14 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { hashLine } from "../src/core/reviews.ts"; |
| 8 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 8 | 9 | |
| 9 | const gitEnv = { | |
| 10 | ...process.env, | |
| 10 | const gitEnv = gitClientEnv({ | |
| 11 | 11 | GIT_AUTHOR_NAME: "Rev", |
| 12 | 12 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 13 | 13 | GIT_COMMITTER_NAME: "Rev", |
| 14 | 14 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 15 | GIT_TERMINAL_PROMPT: "0", | |
| 16 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 17 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 18 | }; | |
| 15 | }); | |
| 19 | 16 | |
| 20 | 17 | describe("review comments on diff lines", () => { |
| 21 | 18 | let dataDir: string; |
test/security-merge.test.ts +3 −6
| @@ -14,17 +14,14 @@ import { tmpdir } from "node:os"; | ||
| 14 | 14 | import { join } from "node:path"; |
| 15 | 15 | import { createApp, type App } from "../src/app.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 17 | 18 | |
| 18 | const gitEnv = { | |
| 19 | ...process.env, | |
| 19 | const gitEnv = gitClientEnv({ | |
| 20 | 20 | GIT_AUTHOR_NAME: "Dev", |
| 21 | 21 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 22 | 22 | GIT_COMMITTER_NAME: "Dev", |
| 23 | 23 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 24 | GIT_TERMINAL_PROMPT: "0", | |
| 25 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 26 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 27 | }; | |
| 24 | }); | |
| 28 | 25 | |
| 29 | 26 | describe("security: read-only token scope bypass on web mutation routes", () => { |
| 30 | 27 | let dataDir: string; |
test/security-xss.test.ts +3 −6
| @@ -11,17 +11,14 @@ import { tmpdir } from "node:os"; | ||
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 14 | 15 | |
| 15 | const clientEnv = { | |
| 16 | ...process.env, | |
| 16 | const clientEnv = gitClientEnv({ | |
| 17 | 17 | GIT_AUTHOR_NAME: "Alice", |
| 18 | 18 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 19 | 19 | GIT_COMMITTER_NAME: "Alice", |
| 20 | 20 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 21 | GIT_TERMINAL_PROMPT: "0", | |
| 22 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 23 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 24 | }; | |
| 21 | }); | |
| 25 | 22 | |
| 26 | 23 | async function sh(args: string[], cwd: string) { |
| 27 | 24 | const proc = Bun.spawn(args, { |
test/ssh-app.test.ts +5 −2
| @@ -20,12 +20,15 @@ import { MSG, Writer } from "../src/ssh/wire.ts"; | ||
| 20 | 20 | import { createLog } from "../src/kernel/log.ts"; |
| 21 | 21 | import { serveForgeSsh } from "../src/tui/ssh.ts"; |
| 22 | 22 | import type { SshServer } from "../src/ssh/server.ts"; |
| 23 | import { missing } from "./helpers/tools.ts"; | |
| 23 | 24 | |
| 24 | 25 | setLogLevel("error"); |
| 25 | 26 | const log = createLog("ssh-app-test"); |
| 26 | 27 | |
| 27 | 28 | const SSH = "/usr/bin/ssh"; |
| 28 | 29 | const SSH_KEYGEN = "/usr/bin/ssh-keygen"; |
| 30 | /** These drive the real OpenSSH client; without it they skip, loudly. */ | |
| 31 | const NO_OPENSSH = missing(SSH, SSH_KEYGEN); | |
| 29 | 32 | |
| 30 | 33 | /** Strip SGR, cursor moves and the rest of what a TUI paints. */ |
| 31 | 34 | function plain(bytes: Uint8Array): string { |
| @@ -87,7 +90,7 @@ async function connect( | ||
| 87 | 90 | return { out: plain(await stdout), code }; |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | describe("ssh into the forge", () => { | |
| 93 | describe.skipIf(NO_OPENSSH)("ssh into the forge", () => { | |
| 91 | 94 | let dataDir: string; |
| 92 | 95 | let keyDir: string; |
| 93 | 96 | let forge: Forge; |
| @@ -151,7 +154,7 @@ describe("ssh into the forge", () => { | ||
| 151 | 154 | }); |
| 152 | 155 | }); |
| 153 | 156 | |
| 154 | describe("channel write backpressure", () => { | |
| 157 | describe.skipIf(NO_OPENSSH)("channel write backpressure", () => { | |
| 155 | 158 | /** A channel whose peer granted a tiny window — the clone case, in small. */ |
| 156 | 159 | function harness(remoteWindow: number) { |
| 157 | 160 | const sent: Uint8Array[] = []; |
test/ssh.test.ts +18 −11
| @@ -24,9 +24,12 @@ import { | ||
| 24 | 24 | type SshServerOptions, |
| 25 | 25 | type SshSession, |
| 26 | 26 | } from "../src/ssh/server.ts"; |
| 27 | import { missing } from "./helpers/tools.ts"; | |
| 27 | 28 | |
| 28 | 29 | const SSH = "/usr/bin/ssh"; |
| 29 | 30 | const SSH_KEYGEN = "/usr/bin/ssh-keygen"; |
| 31 | /** These drive the real OpenSSH client; without it they skip, loudly. */ | |
| 32 | const NO_OPENSSH = missing(SSH, SSH_KEYGEN); | |
| 30 | 33 | const log = createLog("ssh-test"); |
| 31 | 34 | |
| 32 | 35 | let root = ""; |
| @@ -354,6 +357,10 @@ function probe( | ||
| 354 | 357 | } |
| 355 | 358 | |
| 356 | 359 | beforeAll(async () => { |
| 360 | // This hook belongs to the file, not to a describe, so it runs even when | |
| 361 | // every case in it is skipped: with no ssh-keygen there are no keys to | |
| 362 | // make, and nothing that wanted them will run. | |
| 363 | if (NO_OPENSSH) return; | |
| 357 | 364 | root = mkdtempSync(join(tmpdir(), "forge-ssh-")); |
| 358 | 365 | const specs: [string, string[]][] = [ |
| 359 | 366 | ["ed25519", ["-t", "ed25519"]], |
| @@ -377,7 +384,7 @@ afterAll(() => { | ||
| 377 | 384 | if (root) rmSync(root, { recursive: true, force: true }); |
| 378 | 385 | }); |
| 379 | 386 | |
| 380 | describe("ssh drain", () => { | |
| 387 | describe.skipIf(NO_OPENSSH)("ssh drain", () => { | |
| 381 | 388 | test("a draining server keeps who is connected, answers nobody new, and ends when the last leaves", async () => { |
| 382 | 389 | const server = await start({ onSession: () => {} }); |
| 383 | 390 | const opened = await Bun.connect({ |
| @@ -417,7 +424,7 @@ describe("ssh drain", () => { | ||
| 417 | 424 | }); |
| 418 | 425 | }); |
| 419 | 426 | |
| 420 | describe("ssh exec sessions", () => { | |
| 427 | describe.skipIf(NO_OPENSSH)("ssh exec sessions", () => { | |
| 421 | 428 | test("runs a command, returns output and an exit status", async () => { |
| 422 | 429 | let seen: SshSession | undefined; |
| 423 | 430 | const server = await start({ |
| @@ -508,7 +515,7 @@ describe("ssh exec sessions", () => { | ||
| 508 | 515 | }); |
| 509 | 516 | }); |
| 510 | 517 | |
| 511 | describe("ssh shell sessions", () => { | |
| 518 | describe.skipIf(NO_OPENSSH)("ssh shell sessions", () => { | |
| 512 | 519 | test("pty-req, echo, and EOF ending the session", async () => { |
| 513 | 520 | let seen: SshSession | undefined; |
| 514 | 521 | let ended = false; |
| @@ -584,7 +591,7 @@ describe("ssh shell sessions", () => { | ||
| 584 | 591 | }); |
| 585 | 592 | }); |
| 586 | 593 | |
| 587 | describe("ssh publickey authentication", () => { | |
| 594 | describe.skipIf(NO_OPENSSH)("ssh publickey authentication", () => { | |
| 588 | 595 | const cases: [string, string][] = [ |
| 589 | 596 | ["ed25519", "ssh-ed25519"], |
| 590 | 597 | ["ecdsa", "ecdsa-sha2-nistp256"], |
| @@ -696,7 +703,7 @@ describe("ssh publickey authentication", () => { | ||
| 696 | 703 | }); |
| 697 | 704 | }); |
| 698 | 705 | |
| 699 | describe("ssh flow control", () => { | |
| 706 | describe.skipIf(NO_OPENSSH)("ssh flow control", () => { | |
| 700 | 707 | const SIZE = 3 * 1024 * 1024; |
| 701 | 708 | |
| 702 | 709 | test("server writes 3 MiB past the peer window", async () => { |
| @@ -738,7 +745,7 @@ describe("ssh flow control", () => { | ||
| 738 | 745 | }, 30_000); |
| 739 | 746 | }); |
| 740 | 747 | |
| 741 | describe("ssh rekey", () => { | |
| 748 | describe.skipIf(NO_OPENSSH)("ssh rekey", () => { | |
| 742 | 749 | test("survives client-initiated rekey with RekeyLimit=64K", async () => { |
| 743 | 750 | const size = 1024 * 1024; |
| 744 | 751 | const down = payload(size); |
| @@ -775,7 +782,7 @@ describe("ssh rekey", () => { | ||
| 775 | 782 | }, 60_000); |
| 776 | 783 | }); |
| 777 | 784 | |
| 778 | describe("ssh resilience", () => { | |
| 785 | describe.skipIf(NO_OPENSSH)("ssh resilience", () => { | |
| 779 | 786 | test("garbage after the version line is disconnected, server keeps serving", async () => { |
| 780 | 787 | const server = await start({ |
| 781 | 788 | onSession(session) { |
| @@ -853,7 +860,7 @@ describe("ssh resilience", () => { | ||
| 853 | 860 | } |
| 854 | 861 | }); |
| 855 | 862 | |
| 856 | describe("ssh host key", () => { | |
| 863 | describe.skipIf(NO_OPENSSH)("ssh host key", () => { | |
| 857 | 864 | test("persists across restarts and matches ssh-keygen", async () => { |
| 858 | 865 | const dir = mkdtempSync(join(root, "persist-")); |
| 859 | 866 | const first = await start({ hostKeyDir: dir, onSession: (s) => s.exit(0) }); |
| @@ -877,7 +884,7 @@ describe("ssh host key", () => { | ||
| 877 | 884 | }); |
| 878 | 885 | }); |
| 879 | 886 | |
| 880 | describe("ssh key exchange discipline", () => { | |
| 887 | describe.skipIf(NO_OPENSSH)("ssh key exchange discipline", () => { | |
| 881 | 888 | test("a client that never sends NEWKEYS cannot smuggle USERAUTH_REQUEST through in plaintext", async () => { |
| 882 | 889 | let authCalls = 0; |
| 883 | 890 | let sessionCalls = 0; |
| @@ -938,7 +945,7 @@ describe("ssh key exchange discipline", () => { | ||
| 938 | 945 | * the connection layer directly with hand-built wire payloads so the |
| 939 | 946 | * dimension, resize and rejection paths are still covered. |
| 940 | 947 | */ |
| 941 | describe("ssh channel layer", () => { | |
| 948 | describe.skipIf(NO_OPENSSH)("ssh channel layer", () => { | |
| 942 | 949 | function harness() { |
| 943 | 950 | const sent: Uint8Array[] = []; |
| 944 | 951 | let session: SshSession | undefined; |
| @@ -1110,7 +1117,7 @@ describe("ssh channel layer", () => { | ||
| 1110 | 1117 | }); |
| 1111 | 1118 | }); |
| 1112 | 1119 | |
| 1113 | describe("ssh fuzzing", () => { | |
| 1120 | describe.skipIf(NO_OPENSSH)("ssh fuzzing", () => { | |
| 1114 | 1121 | test("random garbage on many connections never takes the server down", async () => { |
| 1115 | 1122 | const server = await start({ |
| 1116 | 1123 | // Random bytes are often merely *incomplete* rather than illegal — a |
test/studio.test.ts +9 −1
| @@ -5014,7 +5014,15 @@ describe("a person's own change ships from its own studio page", () => { | ||
| 5014 | 5014 | ).text(); |
| 5015 | 5015 | expect(ownerPage).toContain("<h2>Decide</h2>"); |
| 5016 | 5016 | expect(ownerPage).toContain("Ship"); |
| 5017 | expect(ownerPage).toContain("The check is green."); | |
| 5017 | // The words a person's own green change is offered in are `asks.ts`'s | |
| 5018 | // (#224: a green change asks to be looked at whoever opened it, and the | |
| 5019 | // card says so without pretending somebody finished it for them). The | |
| 5020 | // `own` fallback in `studio/change.ts` — "The check is green." — is what | |
| 5021 | // an autonomous repository's own pull still gets, where the asks panel | |
| 5022 | // offers nothing. | |
| 5023 | expect(ownerPage).toContain( | |
| 5024 | "is green — ready for a look. Ship it, or say what is off.", | |
| 5025 | ); | |
| 5018 | 5026 | |
| 5019 | 5027 | const shipped = await post( |
| 5020 | 5028 | `/alice/garden/pulls/${pull.number}/ship`, |
test/tui-local.test.ts +3 −4
| @@ -19,18 +19,17 @@ import { | ||
| 19 | 19 | } from "../src/tui/term/terminal.ts"; |
| 20 | 20 | import { createLocalApp } from "../src/tui/local/app.ts"; |
| 21 | 21 | import { localBusy, localIdle } from "../src/tui/local/common.ts"; |
| 22 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 22 | 23 | |
| 23 | 24 | async function sh(args: string[], cwd: string): Promise<void> { |
| 24 | 25 | const proc = Bun.spawn(["git", ...args], { |
| 25 | 26 | cwd, |
| 26 | env: { | |
| 27 | ...process.env, | |
| 27 | env: gitClientEnv({ | |
| 28 | 28 | GIT_AUTHOR_NAME: "Ada", |
| 29 | 29 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 30 | 30 | GIT_COMMITTER_NAME: "Ada", |
| 31 | 31 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 32 | LC_ALL: "C", | |
| 33 | }, | |
| 32 | }), | |
| 34 | 33 | stdout: "pipe", |
| 35 | 34 | stderr: "pipe", |
| 36 | 35 | }); |
test/web-ui.test.ts +3 −6
| @@ -17,6 +17,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 17 | 17 | import { getRepo } from "../src/core/repos.ts"; |
| 18 | 18 | import { listLabels, labelsFor, getIssue } from "../src/core/issues.ts"; |
| 19 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| 20 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 20 | 21 | |
| 21 | 22 | const form = (fields: Record<string, string>) => new URLSearchParams(fields); |
| 22 | 23 | |
| @@ -30,16 +31,12 @@ describe("web ui", () => { | ||
| 30 | 31 | let stranger = ""; |
| 31 | 32 | let pullNumber = 0; |
| 32 | 33 | |
| 33 | const gitEnv = { | |
| 34 | ...process.env, | |
| 34 | const gitEnv = gitClientEnv({ | |
| 35 | 35 | GIT_AUTHOR_NAME: "Mel", |
| 36 | 36 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 37 | 37 | GIT_COMMITTER_NAME: "Mel", |
| 38 | 38 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 39 | GIT_TERMINAL_PROMPT: "0", | |
| 40 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 41 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 42 | }; | |
| 39 | }); | |
| 43 | 40 | |
| 44 | 41 | async function git(args: string[], cwd: string): Promise<number> { |
| 45 | 42 | const proc = Bun.spawn(["git", ...args], { |
test/web.test.ts +3 −6
| @@ -16,6 +16,7 @@ import { renderMarkdown } from "../src/web/markdown.ts"; | ||
| 16 | 16 | import { highlightAsset } from "../src/web/asset.ts"; |
| 17 | 17 | import { securityFloor } from "../src/http/respond.ts"; |
| 18 | 18 | import { THEME_NAMES } from "../src/design/themes.ts"; |
| 19 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 19 | 20 | |
| 20 | 21 | describe("html template", () => { |
| 21 | 22 | test("escapes interpolations by default", () => { |
| @@ -285,16 +286,12 @@ describe("web pages end to end", () => { | ||
| 285 | 286 | let cookie = ""; |
| 286 | 287 | let token = ""; |
| 287 | 288 | |
| 288 | const clientEnv = { | |
| 289 | ...process.env, | |
| 289 | const clientEnv = gitClientEnv({ | |
| 290 | 290 | GIT_AUTHOR_NAME: "Ada", |
| 291 | 291 | GIT_AUTHOR_EMAIL: "[email protected]", |
| 292 | 292 | GIT_COMMITTER_NAME: "Ada", |
| 293 | 293 | GIT_COMMITTER_EMAIL: "[email protected]", |
| 294 | GIT_TERMINAL_PROMPT: "0", | |
| 295 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 296 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 297 | }; | |
| 294 | }); | |
| 298 | 295 | |
| 299 | 296 | async function shOk(args: string[], cwd: string): Promise<string> { |
| 300 | 297 | const proc = Bun.spawn(args, { |
new above
offline