Public: anyone can read this; the crew can change it.
Roles, part 5: the outsider suite's member actor tries every takeover path and is refused #2577
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2305-roles-part-5-the-outsider-suite-s-member → main 1 commit fork
3 files
changed +345
−82
test/contain.test.ts +1 −82
| @@ -47,6 +47,7 @@ import type { AppRecord } from "../src/core/apps.ts"; | ||
| 47 | 47 | import type { Repo } from "../src/core/repos.ts"; |
| 48 | 48 | import { serve, type Servers } from "../src/http/server.ts"; |
| 49 | 49 | import { readRunLog } from "../src/runs/log.ts"; |
| 50 | import { expectContained, probeSource } from "./helpers/cell-probe.ts"; | |
| 50 | 51 | import { missing } from "./helpers/tools.ts"; |
| 51 | 52 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 52 | 53 | |
| @@ -497,88 +498,6 @@ const noCells = | ||
| 497 | 498 | process.platform !== "linux" || |
| 498 | 499 | portInsteadOfSocket(); |
| 499 | 500 | |
| 500 | /** What the probe tries, from inside a deploy or a check. */ | |
| 501 | function probeSource(targets: { | |
| 502 | dataDir: string; | |
| 503 | otherAppDir: string; | |
| 504 | canary: string; | |
| 505 | listenerPort: number; | |
| 506 | /** The forge's own pid: its environment holds every secret it was started with. */ | |
| 507 | forgePid: number; | |
| 508 | }): string { | |
| 509 | return ` | |
| 510 | import { readdirSync, readFileSync } from "node:fs"; | |
| 511 | const T = ${JSON.stringify(targets)}; | |
| 512 | const reached = (detail) => "reached: " + String(detail).slice(0, 80); | |
| 513 | async function attempt(fn) { | |
| 514 | try { | |
| 515 | const found = await fn(); | |
| 516 | return found === false ? "refused" : reached(found); | |
| 517 | } catch { | |
| 518 | return "refused"; | |
| 519 | } | |
| 520 | } | |
| 521 | const answered = async (url, init) => { | |
| 522 | const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) }); | |
| 523 | await res.body?.cancel(); | |
| 524 | return res.ok ? res.status : false; | |
| 525 | }; | |
| 526 | async function probe() { | |
| 527 | return { | |
| 528 | forgeDb: await attempt(() => readFileSync(T.dataDir + "/forge.db").length), | |
| 529 | secretKey: await attempt(() => readFileSync(T.dataDir + "/secret.key").length), | |
| 530 | forgeData: await attempt(() => { | |
| 531 | const names = readdirSync(T.dataDir); | |
| 532 | return names.some((n) => n === "forge.db" || n === "secret.key" || n === "repos") && names.join(","); | |
| 533 | }), | |
| 534 | otherApp: await attempt(() => readdirSync(T.otherAppDir).join(",")), | |
| 535 | environment: await attempt(() => { | |
| 536 | if (Object.values(process.env).some((v) => v?.includes(T.canary))) return "own env"; | |
| 537 | // The pid is the forge's only outside a pid namespace of its own; inside | |
| 538 | // one the same number may be a process of the cell's. | |
| 539 | const cmdline = readFileSync("/proc/" + T.forgePid + "/cmdline", "latin1"); | |
| 540 | const forge = readFileSync("/proc/" + T.forgePid + "/environ", "latin1"); | |
| 541 | if (cmdline.includes("test") && forge.length > 0) return "the forge's environment, " + forge.length + " bytes"; | |
| 542 | for (const pid of readdirSync("/proc")) { | |
| 543 | if (!/^\\d+$/.test(pid)) continue; | |
| 544 | try { | |
| 545 | if (readFileSync("/proc/" + pid + "/environ", "latin1").includes(T.canary)) return "pid " + pid; | |
| 546 | } catch {} | |
| 547 | } | |
| 548 | return false; | |
| 549 | }), | |
| 550 | undeclaredHost: await attempt(() => answered("http://example.com/")), | |
| 551 | cluster: await attempt(() => answered("http://127.0.0.1:" + T.listenerPort + "/")), | |
| 552 | declaredButPrivate: await attempt(() => | |
| 553 | answered("http://localhost:" + T.listenerPort + "/", { proxy: process.env.HTTP_PROXY ?? undefined }), | |
| 554 | ), | |
| 555 | forge: await attempt(() => answered(process.env.FORGE_URL + "/healthz")), | |
| 556 | }; | |
| 557 | } | |
| 558 | if (process.argv[2] === "check") { | |
| 559 | console.log("PROBE " + JSON.stringify(await probe())); | |
| 560 | // A terminal is the app's own client: its app answers it on its port. | |
| 561 | if (process.env.FORGE_APP_TTY === "1") | |
| 562 | console.log("OWNAPP " + (await attempt(() => answered("http://127.0.0.1:" + process.env.PORT + "/")))); | |
| 563 | } else { | |
| 564 | Bun.serve({ | |
| 565 | port: Number(process.env.PORT), | |
| 566 | hostname: "127.0.0.1", | |
| 567 | fetch: async (req) => | |
| 568 | new URL(req.url).pathname === "/probe" ? Response.json(await probe()) : new Response("ok"), | |
| 569 | }); | |
| 570 | } | |
| 571 | `; | |
| 572 | } | |
| 573 | ||
| 574 | /** Everything refused, and the forge — the one declared way out — answering. */ | |
| 575 | function expectContained(results: Record<string, string>): void { | |
| 576 | const { forge, ...rest } = results; | |
| 577 | for (const [name, outcome] of Object.entries(rest)) | |
| 578 | expect(`${name}: ${outcome}`).toBe(`${name}: refused`); | |
| 579 | expect(forge).toStartWith("reached"); | |
| 580 | } | |
| 581 | ||
| 582 | 501 | describe.skipIf(noCells)( |
| 583 | 502 | "cells: repository code cannot take over the forge", |
| 584 | 503 | () => { |
test/helpers/cell-probe.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | import { expect } from "bun:test"; | |
| 2 | ||
| 3 | /** What the probe tries, from inside a deploy or a check. */ | |
| 4 | export function probeSource(targets: { | |
| 5 | dataDir: string; | |
| 6 | otherAppDir: string; | |
| 7 | canary: string; | |
| 8 | listenerPort: number; | |
| 9 | /** The forge's own pid: its environment holds every secret it was started with. */ | |
| 10 | forgePid: number; | |
| 11 | }): string { | |
| 12 | return ` | |
| 13 | import { readdirSync, readFileSync } from "node:fs"; | |
| 14 | const T = ${JSON.stringify(targets)}; | |
| 15 | const reached = (detail) => "reached: " + String(detail).slice(0, 80); | |
| 16 | async function attempt(fn) { | |
| 17 | try { | |
| 18 | const found = await fn(); | |
| 19 | return found === false ? "refused" : reached(found); | |
| 20 | } catch { | |
| 21 | return "refused"; | |
| 22 | } | |
| 23 | } | |
| 24 | const answered = async (url, init) => { | |
| 25 | const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) }); | |
| 26 | await res.body?.cancel(); | |
| 27 | return res.ok ? res.status : false; | |
| 28 | }; | |
| 29 | async function probe() { | |
| 30 | return { | |
| 31 | forgeDb: await attempt(() => readFileSync(T.dataDir + "/forge.db").length), | |
| 32 | secretKey: await attempt(() => readFileSync(T.dataDir + "/secret.key").length), | |
| 33 | forgeData: await attempt(() => { | |
| 34 | const names = readdirSync(T.dataDir); | |
| 35 | return names.some((n) => n === "forge.db" || n === "secret.key" || n === "repos") && names.join(","); | |
| 36 | }), | |
| 37 | otherApp: await attempt(() => readdirSync(T.otherAppDir).join(",")), | |
| 38 | environment: await attempt(() => { | |
| 39 | if (Object.values(process.env).some((v) => v?.includes(T.canary))) return "own env"; | |
| 40 | // The pid is the forge's only outside a pid namespace of its own; inside | |
| 41 | // one the same number may be a process of the cell's. | |
| 42 | const cmdline = readFileSync("/proc/" + T.forgePid + "/cmdline", "latin1"); | |
| 43 | const forge = readFileSync("/proc/" + T.forgePid + "/environ", "latin1"); | |
| 44 | if (cmdline.includes("test") && forge.length > 0) return "the forge's environment, " + forge.length + " bytes"; | |
| 45 | for (const pid of readdirSync("/proc")) { | |
| 46 | if (!/^\\d+$/.test(pid)) continue; | |
| 47 | try { | |
| 48 | if (readFileSync("/proc/" + pid + "/environ", "latin1").includes(T.canary)) return "pid " + pid; | |
| 49 | } catch {} | |
| 50 | } | |
| 51 | return false; | |
| 52 | }), | |
| 53 | undeclaredHost: await attempt(() => answered("http://example.com/")), | |
| 54 | cluster: await attempt(() => answered("http://127.0.0.1:" + T.listenerPort + "/")), | |
| 55 | declaredButPrivate: await attempt(() => | |
| 56 | answered("http://localhost:" + T.listenerPort + "/", { proxy: process.env.HTTP_PROXY ?? undefined }), | |
| 57 | ), | |
| 58 | forge: await attempt(() => answered(process.env.FORGE_URL + "/healthz")), | |
| 59 | }; | |
| 60 | } | |
| 61 | if (process.argv[2] === "check") { | |
| 62 | console.log("PROBE " + JSON.stringify(await probe())); | |
| 63 | // A terminal is the app's own client: its app answers it on its port. | |
| 64 | if (process.env.FORGE_APP_TTY === "1") | |
| 65 | console.log("OWNAPP " + (await attempt(() => answered("http://127.0.0.1:" + process.env.PORT + "/")))); | |
| 66 | } else { | |
| 67 | Bun.serve({ | |
| 68 | port: Number(process.env.PORT), | |
| 69 | hostname: "127.0.0.1", | |
| 70 | fetch: async (req) => | |
| 71 | new URL(req.url).pathname === "/probe" ? Response.json(await probe()) : new Response("ok"), | |
| 72 | }); | |
| 73 | } | |
| 74 | `; | |
| 75 | } | |
| 76 | ||
| 77 | /** Everything refused, and the forge — the one declared way out — answering. */ | |
| 78 | export function expectContained(results: Record<string, string>): void { | |
| 79 | const { forge, ...rest } = results; | |
| 80 | for (const [name, outcome] of Object.entries(rest)) | |
| 81 | expect(`${name}: ${outcome}`).toBe(`${name}: refused`); | |
| 82 | expect(forge).toStartWith("reached"); | |
| 83 | } |
test/outsiders-member.test.ts added +261
| @@ -0,0 +1,261 @@ | ||
| 1 | /** | |
| 2 | * The outsider suite's member actor (#2305, the last part of #673). A member | |
| 3 | * is trusted to build, not to take over: each takeover path is tried as one | |
| 4 | * and each is refused — the forge's data, secret key and environment from an | |
| 5 | * app, a check and a crew shell; the forge's own model credential; a token | |
| 6 | * wider than the member's own; another account's repository. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { enableApp } from "../src/actions/apps.ts"; | |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { signUp } from "../src/actions/users.ts"; | |
| 18 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 19 | import { cellsAvailable } from "../src/apps/contain.ts"; | |
| 20 | import { envDir } from "../src/apps/data.ts"; | |
| 21 | import { childEnv } from "../src/apps/process.ts"; | |
| 22 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 23 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 24 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 25 | import { ADMIN_TOKEN_WORDS, createToken } from "../src/core/tokens.ts"; | |
| 26 | import { getUserByName, type User } from "../src/core/users.ts"; | |
| 27 | import { getUsage } from "../src/core/usage.ts"; | |
| 28 | import { sandboxPolicy } from "../src/crew/sandbox.ts"; | |
| 29 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; | |
| 32 | import { readRunLog } from "../src/runs/log.ts"; | |
| 33 | import { expectContained, probeSource } from "./helpers/cell-probe.ts"; | |
| 34 | import { agentRunSettled, startStandInCrew, stopStandInCrew } from "./helpers/crew-standin.ts"; | |
| 35 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 36 | import { quietLogs } from "./helpers/globals.ts"; | |
| 37 | import { promote } from "./helpers/members.ts"; | |
| 38 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 39 | import { missing } from "./helpers/tools.ts"; | |
| 40 | ||
| 41 | quietLogs(); | |
| 42 | ||
| 43 | const PASS = "a-long-member-passphrase"; | |
| 44 | const CANARY = `forge-canary-${crypto.randomUUID()}`; | |
| 45 | ||
| 46 | // Cells bind unix sockets, which a port-mode sandbox refuses; the forge's check runs them. | |
| 47 | const noCells = | |
| 48 | process.env["FORGE_CELLS"] === "off" || | |
| 49 | missing("bwrap", "socat") || | |
| 50 | process.platform !== "linux" || | |
| 51 | portInsteadOfSocket(); | |
| 52 | ||
| 53 | describe("the member actor", () => { | |
| 54 | let dataDir = ""; | |
| 55 | let app: App; | |
| 56 | let servers: Servers; | |
| 57 | let olive: User; | |
| 58 | let mallory: User; | |
| 59 | let malloryToken = ""; | |
| 60 | let ledger: Repo; | |
| 61 | let listener: ReturnType<typeof Bun.serve>; | |
| 62 | let runtime: AppsRuntime; | |
| 63 | let checks: ChecksHandle; | |
| 64 | ||
| 65 | const call = (path: string, init: RequestInit & { json?: unknown } = {}) => | |
| 66 | fetch(`${servers.url}${path}`, { | |
| 67 | ...init, | |
| 68 | headers: { | |
| 69 | authorization: `Bearer ${malloryToken}`, | |
| 70 | "content-type": "application/json", | |
| 71 | ...init.headers, | |
| 72 | }, | |
| 73 | ...(init.json === undefined ? {} : { body: JSON.stringify(init.json) }), | |
| 74 | }); | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | process.env["FORGE_TEST_CANARY"] = CANARY; | |
| 78 | dataDir = await mkdtemp(join(tmpdir(), "forge-outsiders-member-")); | |
| 79 | app = createApp({ dataDir, port: 0 }); | |
| 80 | servers = await serve(app); | |
| 81 | const seeded = await seedAdmin(app, { username: "olive", email: "[email protected]", password: PASS }); | |
| 82 | if (!seeded.ok) throw new Error(seeded.error); | |
| 83 | olive = seeded.value; | |
| 84 | const made = await signUp(app, { username: "mallory", email: "[email protected]", password: PASS }, { slot: fixtureSlot(app) }); | |
| 85 | if (!made.ok) throw new Error(made.error); | |
| 86 | promote(app.db, made.value); | |
| 87 | mallory = getUserByName(app.db, "mallory")!; | |
| 88 | malloryToken = createToken(app.db, mallory.id, "t", "rw").token; | |
| 89 | const theirs = await createRepo(app, olive, { name: "ledger", visibility: "public" }); | |
| 90 | if (!theirs.ok) throw new Error(String(theirs.error)); | |
| 91 | ledger = theirs.value; | |
| 92 | const seed = await commitToBranch(app, olive, ledger, { | |
| 93 | message: "Start", | |
| 94 | files: [{ path: "README.md", content: "# ledger\n" }], | |
| 95 | }); | |
| 96 | if (!seed.ok) throw new Error(JSON.stringify(seed.error)); | |
| 97 | if (!noCells) { | |
| 98 | runtime = new AppsRuntime(app, { | |
| 99 | domain: "member.test", | |
| 100 | scheme: "http", | |
| 101 | publicPort: servers.port, | |
| 102 | forgeUrl: servers.url, | |
| 103 | concurrency: 2, | |
| 104 | }); | |
| 105 | await runtime.start(); | |
| 106 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); | |
| 107 | listener = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("inside") }); | |
| 108 | } | |
| 109 | }, 60_000); | |
| 110 | ||
| 111 | afterAll(async () => { | |
| 112 | delete process.env["FORGE_TEST_CANARY"]; | |
| 113 | await checks?.stop(); | |
| 114 | await runtime?.stop(); | |
| 115 | listener?.stop(true); | |
| 116 | servers?.stop(); | |
| 117 | app?.close(); | |
| 118 | await rm(dataDir, { recursive: true, force: true }); | |
| 119 | }); | |
| 120 | ||
| 121 | describe("reads the forge's data, key and environment", () => { | |
| 122 | test("from a crew shell: every entry of the data directory but crew/ is denied, and the environment is cut", () => { | |
| 123 | for (const dir of ["repos/olive", "apps/01app/production", "checks/01c", "crew/scout/01r/1"]) | |
| 124 | mkdirSync(join(dataDir, dir), { recursive: true }); | |
| 125 | writeFileSync(join(dataDir, "secret.key"), "x"); | |
| 126 | const { denyRead } = sandboxPolicy({ dataDir, agentName: "scout", urls: [], runId: "01m46abcdefghjkmnpqrstvwxy" }); | |
| 127 | const denied = (p: string) => denyRead.some((d) => p === d || p.startsWith(`${d}/`)); | |
| 128 | for (const entry of ["forge.db", "secret.key", "repos", "apps", "checks"]) | |
| 129 | expect([entry, denied(join(dataDir, entry))]).toEqual([entry, true]); | |
| 130 | expect(denied(join(dataDir, "crew/scout/01r/1"))).toBe(false); | |
| 131 | // What a shell, a check or an app starts from names no forge secret. | |
| 132 | const env = childEnv({}); | |
| 133 | expect(JSON.stringify(env)).not.toContain(CANARY); | |
| 134 | expect(Object.keys(env).filter((k) => /KEY|TOKEN|SECRET|PASSWORD/.test(k))).toEqual([]); | |
| 135 | }); | |
| 136 | ||
| 137 | test.skipIf(noCells)("from an app, its preview and a check: each is refused, the forge still answers", async () => { | |
| 138 | expect(await cellsAvailable()).toEqual({ ok: true }); | |
| 139 | const made = await createRepo(app, mallory, { name: "probe", visibility: "public" }); | |
| 140 | if (!made.ok) throw new Error(String(made.error)); | |
| 141 | const repo = made.value; | |
| 142 | // Another tenant's app data: there is something to steal. | |
| 143 | const theirs = join(envDir(dataDir, "01other", "production"), "data"); | |
| 144 | mkdirSync(theirs, { recursive: true }); | |
| 145 | writeFileSync(join(theirs, "books.txt"), "private"); | |
| 146 | const committed = await commitToBranch(app, mallory, repo, { | |
| 147 | message: "A probe", | |
| 148 | files: [ | |
| 149 | { | |
| 150 | path: "forge.json", | |
| 151 | content: JSON.stringify({ | |
| 152 | egress: ["localhost"], | |
| 153 | app: { run: "bun probe.ts serve", health: "/" }, | |
| 154 | check: { run: "bun probe.ts check", timeout: 120 }, | |
| 155 | }), | |
| 156 | }, | |
| 157 | { | |
| 158 | path: "probe.ts", | |
| 159 | content: probeSource({ | |
| 160 | dataDir, | |
| 161 | otherAppDir: envDir(dataDir, "01other", "production"), | |
| 162 | canary: CANARY, | |
| 163 | listenerPort: listener.port ?? 0, | |
| 164 | forgePid: process.pid, | |
| 165 | }), | |
| 166 | }, | |
| 167 | ], | |
| 168 | }); | |
| 169 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 170 | const record = enableApp(app, mallory, repo, { slug: "probe" }); | |
| 171 | if (!record.ok) throw new Error(String(record.error)); | |
| 172 | ||
| 173 | const settled = async (kind: "deploy" | "check"): Promise<Run> => { | |
| 174 | const deadline = Date.now() + 90_000; | |
| 175 | while (Date.now() < deadline) { | |
| 176 | const run = listRuns(app.db, repo.id, { kind, limit: 50 }).find((r) => r.commitOid === committed.value.commitOid); | |
| 177 | if (run && ["ok", "failed", "cancelled"].includes(run.state)) return run; | |
| 178 | await Bun.sleep(100); | |
| 179 | } | |
| 180 | throw new Error(`${kind} did not settle`); | |
| 181 | }; | |
| 182 | const deploy = await settled("deploy"); | |
| 183 | expect(deploy.state).toBe("ok"); | |
| 184 | expect(deploy.sandboxEnforced).toBe(true); | |
| 185 | const fromApp = await fetch(`${servers.url}/probe`, { | |
| 186 | headers: { host: new URL(runtime.urlFor(record.value, "production")).host }, | |
| 187 | }); | |
| 188 | expectContained((await fromApp.json()) as Record<string, string>); | |
| 189 | ||
| 190 | const check = await settled("check"); | |
| 191 | expect(check.state).toBe("ok"); | |
| 192 | expect(check.sandboxEnforced).toBe(true); | |
| 193 | const line = readRunLog(dataDir, check.id).lines.find((l) => l.startsWith("PROBE ")); | |
| 194 | expect(line).toBeDefined(); | |
| 195 | expectContained(JSON.parse(line!.slice(6)) as Record<string, string>); | |
| 196 | }, 240_000); | |
| 197 | }); | |
| 198 | ||
| 199 | describe("widens a token", () => { | |
| 200 | test("minting admin scope is a 403 in one sentence; an rw token reaches no admin door", async () => { | |
| 201 | const minted = (scope: string) => | |
| 202 | fetch(`${servers.url}/api/v1/tokens`, { | |
| 203 | method: "POST", | |
| 204 | headers: { "content-type": "application/json" }, | |
| 205 | body: JSON.stringify({ username: "mallory", password: PASS, name: "w", scope, adminConfirm: true }), | |
| 206 | }); | |
| 207 | const denied = await minted("admin"); | |
| 208 | expect(denied.status).toBe(403); | |
| 209 | expect(((await denied.json()) as { message: string }).message).toBe(ADMIN_TOKEN_WORDS); | |
| 210 | expect((await call("/api/v1/admin/accounts")).status).toBe(403); | |
| 211 | expect((await call("/api/v1/admin/accounts/olive/suspend", { method: "POST" })).status).toBe(403); | |
| 212 | expect(getUserByName(app.db, "olive")!.suspendedAt).toBeFalsy(); | |
| 213 | }); | |
| 214 | }); | |
| 215 | ||
| 216 | describe("pushes to or administers another account's repository", () => { | |
| 217 | test("a push, a commit over the API, a settings change and a delete are all refused, and the repository is as it was", async () => { | |
| 218 | const before = JSON.stringify(getRepo(app.db, "olive", "ledger")); | |
| 219 | const basic = `Basic ${btoa(`mallory:${malloryToken}`)}`; | |
| 220 | const adv = await fetch(`${servers.url}/olive/ledger.git/info/refs?service=git-receive-pack`, { | |
| 221 | headers: { authorization: basic }, | |
| 222 | }); | |
| 223 | expect(adv.status).toBeGreaterThanOrEqual(401); | |
| 224 | expect(adv.status).toBeLessThan(500); | |
| 225 | const tries: [string, string, unknown?][] = [ | |
| 226 | ["POST", "/api/v1/repos/olive/ledger/contents", { message: "mine", files: [{ path: "README.md", content: "owned\n" }] }], | |
| 227 | ["PATCH", "/api/v1/repos/olive/ledger", { description: "owned", visibility: "private" }], | |
| 228 | ["DELETE", "/api/v1/repos/olive/ledger"], | |
| 229 | ]; | |
| 230 | for (const [method, path, json] of tries) { | |
| 231 | const res = await call(path, { method, ...(json === undefined ? {} : { json }) }); | |
| 232 | expect([method, path, [403, 404].includes(res.status)]).toEqual([method, path, true]); | |
| 233 | } | |
| 234 | expect(JSON.stringify(getRepo(app.db, "olive", "ledger"))).toBe(before); | |
| 235 | }); | |
| 236 | }); | |
| 237 | }); | |
| 238 | ||
| 239 | describe("the member actor and the forge's own model credential", () => { | |
| 240 | test("a member's repository run never starts an agent with it, and spends nothing", async () => { | |
| 241 | const standIn = await startStandInCrew(); | |
| 242 | process.env["ANTHROPIC_API_KEY"] = "sk-forge-own-token"; | |
| 243 | try { | |
| 244 | const { app, alice, bot, repo } = standIn; | |
| 245 | app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(alice.id); | |
| 246 | promote(app.db, alice); | |
| 247 | const issue = openIssue(app, alice, repo, { title: "Prune the roses", body: "Before spring." }); | |
| 248 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 249 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 250 | const run = await agentRunSettled(app, repo, (r) => r.issueId === issue.value.id); | |
| 251 | expect(run.state).toBe("failed"); | |
| 252 | expect(run.summary).toContain("This repository has no model account: ask an admin to grant one"); | |
| 253 | expect(existsSync(standIn.argsFile)).toBe(false); | |
| 254 | expect(getUsage(app.db, run.id)?.costUsd ?? 0).toBe(0); | |
| 255 | expect(run.costUsd).toBe(0); | |
| 256 | } finally { | |
| 257 | delete process.env["ANTHROPIC_API_KEY"]; | |
| 258 | await stopStandInCrew(standIn); | |
| 259 | } | |
| 260 | }, 90_000); | |
| 261 | }); |
new above
offline