Public: anyone can read this; the crew can change it.
Before publishing: decide who reads a public repository's crew run logs, and seal the security work from before sealing existed #1393
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1075-before-publishing-decide-who-reads-a-pub → main 7 commits fork 5 threads · 5 unresolved
docs/APPS.md +6
| @@ -124,6 +124,12 @@ started`; a deploy no push asked for — a redeploy someone pressed, a | ||
| 124 | 124 | follows the same rule: its environments' state and errors (`envsFor`), |
| 125 | 125 | its runs and their logs (`mayReadRuns`) and its run and serving events |
| 126 | 126 | reach only those who may enter it. |
| 127 | On any repository, public too, a run's **log** (the agent's | |
| 128 | transcript, a deploy's output) is read only by its writers and crew | |
| 129 | (`mayReadRunLog`); a stranger reads the run and its summary, not the | |
| 130 | log, on the page, the API and the live tails (#1075). Check runs share | |
| 131 | the rule: on a public repository the author of a pull from outside it | |
| 132 | reads a failed check's summary, not its output. | |
| 127 | 133 | 6. **A run's credential dies with the run.** An agent run gets a token |
| 128 | 134 | scoped `rw` (no settings, no tokens, no administration) with an expiry, |
| 129 | 135 | and the token is deleted when the run ends however it ends. It never |
scripts/gate.ts +1
| @@ -38,6 +38,7 @@ export const GATE: readonly string[] = [ | ||
| 38 | 38 | "test/contents-api.test.ts", |
| 39 | 39 | "test/crew-agents-only.test.ts", |
| 40 | 40 | "test/crew-brief-audience.test.ts", |
| 41 | "test/run-log-audience.test.ts", | |
| 41 | 42 | "test/crew.test.ts", |
| 42 | 43 | "test/commit-identity.test.ts", |
| 43 | 44 | "test/enterable-apps.test.ts", |
src/actions/runs.ts +25 −2
| @@ -15,7 +15,7 @@ import { steerRun } from "../crew/runner.ts"; | ||
| 15 | 15 | import { getIssueById } from "../core/issues.ts"; |
| 16 | 16 | import { addComment } from "./issues.ts"; |
| 17 | 17 | import { getAppByRepo } from "../core/apps.ts"; |
| 18 | import { canEnterApp, type Repo } from "../core/repos.ts"; | |
| 18 | import { canEnterApp, canSeeOwnerFacts, onCrew, type Repo } from "../core/repos.ts"; | |
| 19 | 19 | import { |
| 20 | 20 | cancelRun as markCancelled, |
| 21 | 21 | getRun, |
| @@ -82,6 +82,23 @@ export function mayReadRuns( | ||
| 82 | 82 | ); |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | /** | |
| 86 | * Whether `actor` may read a run's log — the agent's transcript, or a | |
| 87 | * deploy's output. A summary is public with the run; the log is for the | |
| 88 | * repository's writers and crew only, on a public repository too: it says | |
| 89 | * what the agent ran, read and concluded, in the detail it wrote for itself. | |
| 90 | */ | |
| 91 | export function mayReadRunLog( | |
| 92 | app: App, | |
| 93 | actor: User | undefined, | |
| 94 | repo: Repo, | |
| 95 | ): boolean { | |
| 96 | return ( | |
| 97 | mayReadRuns(app, actor, repo) && | |
outdated
High: |
||
| 98 | (canSeeOwnerFacts(app.db, repo, actor) || onCrew(app.db, repo, actor)) | |
| 99 | ); | |
| 100 | } | |
| 101 | ||
| 85 | 102 | export function listRepoRuns( |
| 86 | 103 | app: App, |
| 87 | 104 | actor: User | undefined, |
| @@ -118,6 +135,8 @@ export interface RunLogView { | ||
| 118 | 135 | readonly lines: string[]; |
| 119 | 136 | readonly truncated: boolean; |
| 120 | 137 | readonly path: string; |
| 138 | /** The reader may see the run but not its log: `lines` is empty. */ | |
| 139 | readonly logHidden: boolean; | |
| 121 | 140 | } |
| 122 | 141 | |
| 123 | 142 | export function runLog( |
| @@ -128,12 +147,16 @@ export function runLog( | ||
| 128 | 147 | ): Result<RunLogView, "not_found"> { |
| 129 | 148 | const found = getRepoRun(app, actor, repo, runId); |
| 130 | 149 | if (!found.ok) return found; |
| 131 | const { lines, truncated } = readRunLog(app.config.dataDir, runId); | |
| 150 | const logHidden = !mayReadRunLog(app, actor, repo); | |
| 151 | const { lines, truncated } = logHidden | |
| 152 | ? { lines: [], truncated: false } | |
| 153 | : readRunLog(app.config.dataDir, runId); | |
| 132 | 154 | return ok({ |
| 133 | 155 | run: found.value, |
| 134 | 156 | lines, |
| 135 | 157 | truncated, |
| 136 | 158 | path: runLogPath(app.config.dataDir, runId), |
| 159 | logHidden, | |
| 137 | 160 | }); |
| 138 | 161 | } |
| 139 | 162 |
src/http/api.ts +1 −1
| @@ -4449,7 +4449,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4449 | 4449 | const repo = loadRepo(ctx); |
| 4450 | 4450 | if (!repo) return notFound(); |
| 4451 | 4451 | const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); |
| 4452 | if (!found.ok) return notFound(); | |
| 4452 | if (!found.ok || found.value.logHidden) return notFound(); | |
| 4453 | 4453 | const follow = |
| 4454 | 4454 | ctx.url.searchParams.get("follow") === "1" || |
| 4455 | 4455 | (ctx.request.headers.get("accept") ?? "").includes("text/event-stream"); |
src/web/pages/runs.ts +2 −2
| @@ -298,8 +298,8 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 298 | 298 | if (!repo) return notFoundPage(ctx); |
| 299 | 299 | const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); |
| 300 | 300 | if (!found.ok) return notFoundPage(ctx); |
| 301 | const { run, lines, truncated } = found.value; | |
| 302 | return await runView(ctx, repo, run, lines, truncated); | |
| 301 | const { run, lines, truncated, logHidden } = found.value; | |
| 302 | return await runView(ctx, repo, run, lines, truncated, logHidden); | |
| 303 | 303 | } |
| 304 | 304 | |
| 305 | 305 | // POST /:owner/:name/runs/:id/steer { text } |
src/web/studio/now.ts +2 −2
| @@ -20,7 +20,7 @@ import { getUsersByIds } from "../../core/users.ts"; | ||
| 20 | 20 | import { repoDir } from "../../git/repos.ts"; |
| 21 | 21 | import type { Ctx } from "../../http/context.ts"; |
| 22 | 22 | import { readRunLogTail } from "../../runs/log.ts"; |
| 23 | import { mayReadRuns } from "../../actions/runs.ts"; | |
| 23 | import { mayReadRunLog } from "../../actions/runs.ts"; | |
| 24 | 24 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 25 | 25 | import { html, type Html } from "../html.ts"; |
| 26 | 26 | import { fragment } from "../layout.ts"; |
| @@ -169,7 +169,7 @@ export async function nowCard( | ||
| 169 | 169 | (r) => r.kind === "agent" && r.state === "running", |
| 170 | 170 | ); |
| 171 | 171 | const activeTail = |
| 172 | activeRun && mayReadRuns(ctx.app, ctx.auth.user, repo) | |
| 172 | activeRun && mayReadRunLog(ctx.app, ctx.auth.user, repo) | |
High: no test covers this gate. If it is reverted to |
||
| 173 | 173 | ? logBox({ |
| 174 | 174 | id: "app-run-tail", |
| 175 | 175 | who: voiceFor(runActor(activeRun), ownerFacts), |
src/web/studio/previews.ts +3 −2
| @@ -194,7 +194,8 @@ function previewsPanel( | ||
| 194 | 194 | * Once the deploy has swapped in, the lines are the app's own — never the |
| 195 | 195 | * deploy's, whose word (`envPhrase`) has already moved on to "live" — and |
| 196 | 196 | * an app's stdout can carry anything the owner never meant for a stranger, |
| 197 | * so only a viewer who can see the owner's facts is shown it at all. | |
| 197 | * so only a viewer who can see the owner's facts is shown it at all — | |
| 198 | * a deploy's too, as it is a log (`mayReadRunLog`). | |
| 198 | 199 | */ |
| 199 | 200 | function productionTail( |
| 200 | 201 | ctx: Ctx, |
| @@ -206,7 +207,7 @@ function productionTail( | ||
| 206 | 207 | if (production === undefined || production.runId === undefined) return none; |
| 207 | 208 | const deploying = production.state === "deploying"; |
| 208 | 209 | if (!deploying && production.state !== "running") return none; |
| 209 | if (!deploying && !writer) return none; | |
| 210 | if (!writer) return none; | |
High: this is a behaviour change (a stranger no longer sees a deploy's output while it is deploying), and no test would fail without it. Pin it: give a public app's production env |
||
| 210 | 211 | const agentBusy = listRuns(ctx.app.db, repo.id, { |
| 211 | 212 | kind: "agent", |
| 212 | 213 | limit: 8, |
src/web/studio/run.ts +11 −3
| @@ -66,6 +66,7 @@ export async function runView( | ||
| 66 | 66 | run: Run, |
| 67 | 67 | lines: readonly string[], |
| 68 | 68 | truncated: boolean, |
| 69 | logHidden = false, | |
| 69 | 70 | ): Promise<Response> { |
| 70 | 71 | const db = ctx.app.db; |
| 71 | 72 | const base = repoBase(repo); |
| @@ -203,6 +204,13 @@ export async function runView( | ||
| 203 | 204 | </div>`, |
| 204 | 205 | )} |
| 205 | 206 | ${when(pull, (p) => previewCard(ctx, repo, p))} |
| 207 | ${when( | |
| 208 | logHidden, | |
| 209 | () => | |
| 210 | html`<p class="muted small" id="run-log-hidden"> | |
| 211 | The log is for this repository's writers and crew. | |
| 212 | </p>`, | |
| 213 | )} | |
| 206 | 214 | ${when( |
| 207 | 215 | truncated, |
| 208 | 216 | () => |
| @@ -211,14 +219,14 @@ export async function runView( | ||
| 211 | 219 | </p>`, |
| 212 | 220 | )} |
| 213 | 221 | ${when( |
| 214 | lines.length === 0, | |
| 222 | lines.length === 0 && !logHidden, | |
| 215 | 223 | () => |
| 216 | 224 | html`<p class="muted small" id="run-empty"> |
| 217 | 225 | Nothing has been logged yet. |
| 218 | 226 | </p>`, |
| 219 | 227 | )} |
| 220 | 228 | <div class="run-live" id="run-live" data-state="${run.state}"> |
| 221 | ${logBox({ | |
| 229 | ${when(!logHidden, () => logBox({ | |
| 222 | 230 | id: "run-log", |
| 223 | 231 | who, |
| 224 | 232 | cap: all |
| @@ -232,7 +240,7 @@ export async function runView( | ||
| 232 | 240 | extra: html`<button type="button" class="run-jump" id="run-jump" hidden> |
| 233 | 241 | newer below |
| 234 | 242 | </button>`, |
| 235 | })} | |
| 243 | }))} | |
| 236 | 244 | ${when( |
| 237 | 245 | run.kind === "agent", |
| 238 | 246 | () => html`<p class="muted small crew-note">${directionNote(repo)}</p>`, |
test/run-log-audience.test.ts added +221
| @@ -0,0 +1,221 @@ | ||
| 1 | /** | |
| 2 | * A public repository's run is public in its summary and private in its log | |
| 3 | * (#1075): the log is the agent's transcript — what it ran, read and | |
| 4 | * concluded — so only the repository's writers and crew read it, on the run | |
| 5 | * page, the API, and the live tails beside it. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { enableApp } from "../src/actions/apps.ts"; | |
outdated
Nit: fold this into the |
||
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 17 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 18 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 19 | import { createAgent } from "../src/actions/crew.ts"; | |
| 20 | import { addCrew } from "../src/core/agents.ts"; | |
| 21 | import { createToken } from "../src/core/tokens.ts"; | |
| 22 | import { createUser, type User } from "../src/core/users.ts"; | |
| 23 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 24 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | const LINE = "R7LOGLINE cat /sandbox/notes.txt"; | |
| 30 | const SUMMARY = "R7SUMMARY fixed the thing"; | |
| 31 | ||
| 32 | let dataDir: string; | |
| 33 | let app: App; | |
| 34 | let http: ForgeHttp; | |
| 35 | let runId: string; | |
| 36 | let runtime: AppsRuntime; | |
| 37 | let appRecord: AppRecord; | |
| 38 | let repoRow: Repo; | |
| 39 | let aliceId: string; | |
| 40 | const token: Record<string, string> = {}; | |
| 41 | ||
| 42 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 43 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 44 | return r.value; | |
| 45 | }; | |
| 46 | ||
| 47 | async function get(path: string, who?: string) { | |
| 48 | const res = await http.handle(new Request(`http://forge.test${path}`, { | |
| 49 | headers: { | |
| 50 | cookie: "forge_mode=studio", | |
| 51 | ...(who === undefined ? {} : { authorization: `Bearer ${token[who]}` }), | |
| 52 | }, | |
| 53 | })); | |
| 54 | return { status: res.status, text: await res.text() }; | |
| 55 | } | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-runlog-")); | |
| 59 | app = createApp({ dataDir, port: 0 }); | |
| 60 | http = forgeHttp(app); | |
| 61 | const alice: User = ok( | |
| 62 | await createUser(app.db, { | |
| 63 | trusted: true, | |
| 64 | username: "alice", | |
| 65 | email: "[email protected]", | |
| 66 | password: "a-good-passphrase", | |
| 67 | adminIfFirst: true, | |
| 68 | }), | |
| 69 | ); | |
| 70 | const mallory = ok( | |
| 71 | await createUser(app.db, { | |
| 72 | trusted: true, | |
| 73 | username: "mallory", | |
| 74 | email: "[email protected]", | |
| 75 | password: "a-good-passphrase", | |
| 76 | }), | |
| 77 | ); | |
| 78 | const repo = ok( | |
| 79 | await createRepo(app, alice, { name: "square", visibility: "public" }), | |
| 80 | ); | |
| 81 | ok( | |
| 82 | await commitToBranch(app, alice, repo, { | |
| 83 | message: "Start", | |
| 84 | files: [{ path: "README.md", content: "# square\n" }], | |
| 85 | }), | |
| 86 | ); | |
| 87 | repoRow = repo; | |
| 88 | aliceId = alice.id; | |
| 89 | runtime = new AppsRuntime(app, { | |
| 90 | domain: "audience.test", | |
| 91 | scheme: "http", | |
| 92 | publicPort: 0, | |
| 93 | forgeUrl: "http://forge.test", | |
| 94 | concurrency: 1, | |
| 95 | }); | |
| 96 | ok(await enableApp(app, alice, repo, { slug: "square" })); | |
| 97 | appRecord = getAppByRepo(app.db, repo.id)!; | |
| 98 | token.alice = createToken(app.db, alice.id, "t", "rw").token; | |
| 99 | token.mallory = createToken(app.db, mallory.id, "t", "rw").token; | |
| 100 | const bot = ok(await createAgent(app, alice, { username: "logbot" })); | |
| 101 | addCrew(app.db, repo.id, bot.user.id, alice.id); | |
| 102 | token.logbot = createToken(app.db, bot.user.id, "t", "rw").token; | |
| 103 | const run = createRun(app.db, { | |
| 104 | repoId: repo.id, | |
| 105 | kind: "agent", | |
| 106 | actorId: alice.id, | |
| 107 | reason: "assigned", | |
| 108 | }); | |
| 109 | claimRun(app.db, run.id); | |
| 110 | runId = run.id; | |
| 111 | appendRunLog(dataDir, runId, LINE); | |
| 112 | finishRun(app.db, runId, { state: "ok", summary: SUMMARY }); | |
| 113 | }, 60_000); | |
| 114 | ||
| 115 | afterAll(async () => { | |
| 116 | await runtime?.stop?.(); | |
| 117 | app?.close(); | |
| 118 | await rm(dataDir, { recursive: true, force: true }); | |
| 119 | }); | |
| 120 | ||
| 121 | describe("run log audience", () => { | |
| 122 | test("a stranger reads a public repository's run summary, not its log", async () => { | |
| 123 | for (const who of [undefined, "mallory"]) { | |
| 124 | const api = await get(`/api/v1/repos/alice/square/runs/${runId}`, who); | |
| 125 | expect(api.status).toBe(200); | |
| 126 | expect(api.text).toContain("R7SUMMARY"); | |
| 127 | ||
| 128 | const page = await get(`/alice/square/runs/${runId}`, who); | |
| 129 | expect(page.status).toBe(200); | |
| 130 | expect(page.text).toContain("R7SUMMARY"); | |
| 131 | expect(page.text).not.toContain("R7LOGLINE"); | |
| 132 | expect(page.text).not.toContain(`/runs/${runId}/log`); | |
| 133 | ||
| 134 | for (const path of [ | |
| 135 | `/api/v1/repos/alice/square/runs/${runId}/log`, | |
| 136 | `/api/v1/repos/alice/square/runs/${runId}/log?follow=1`, | |
| 137 | ]) { | |
| 138 | const log = await get(path, who); | |
| 139 | expect(log.status).toBe(404); | |
| 140 | expect(log.text).not.toContain("R7LOGLINE"); | |
| 141 | } | |
| 142 | } | |
| 143 | }); | |
| 144 | ||
| 145 | test("a writer still reads the log, on the API and the page", async () => { | |
| 146 | const log = await get(`/api/v1/repos/alice/square/runs/${runId}/log`, "alice"); | |
| 147 | expect(log.status).toBe(200); | |
| 148 | expect(log.text).toContain("R7LOGLINE"); | |
| 149 | const page = await get(`/alice/square/runs/${runId}`, "alice"); | |
| 150 | expect(page.text).toContain("R7LOGLINE"); | |
| 151 | }); | |
| 152 | ||
| 153 | test("an agent on the crew reads the log without a write grant", async () => { | |
| 154 | for (const path of [ | |
| 155 | `/api/v1/repos/alice/square/runs/${runId}/log`, | |
| 156 | `/api/v1/repos/alice/square/runs/${runId}/log?follow=1`, | |
| 157 | ]) { | |
| 158 | const log = await get(path, "logbot"); | |
| 159 | expect(log.status).toBe(200); | |
| 160 | expect(log.text).toContain("R7LOGLINE"); | |
| 161 | } | |
| 162 | const page = await get(`/alice/square/runs/${runId}`, "logbot"); | |
| 163 | expect(page.text).toContain("R7LOGLINE"); | |
| 164 | }); | |
| 165 | ||
| 166 | const putEnv = (slot: Record<string, unknown>): void => { | |
| 167 | (runtime as unknown as { envs: Map<string, unknown> }).envs.set( | |
| 168 | `${appRecord.id}/production`, | |
| 169 | { app: appRecord, env: "production", chain: Promise.resolve(), restarts: [], ...slot }, | |
| 170 | ); | |
| 171 | }; | |
| 172 | ||
| 173 | test("a deploying production's output is a writer's, on the app page", async () => { | |
| 174 | const deploy = createRun(app.db, { | |
| 175 | repoId: repoRow.id, | |
| 176 | kind: "deploy", | |
| 177 | env: "production", | |
| 178 | commitOid: "f00dcafe", | |
| 179 | reason: "boot", | |
| 180 | }); | |
| 181 | claimRun(app.db, deploy.id); | |
| 182 | appendRunLog(dataDir, deploy.id, "R7DEPLOYLINE install deps"); | |
| 183 | putEnv({ | |
| 184 | deploying: { | |
| 185 | run: { id: deploy.id, commitOid: "f00dcafe" }, | |
| 186 | abort: new AbortController(), | |
| 187 | }, | |
| 188 | }); | |
| 189 | for (const who of [undefined, "mallory"]) { | |
| 190 | const page = await get("/alice/square", who); | |
| 191 | expect(page.status).toBe(200); | |
| 192 | expect(page.text).not.toContain('id="app-run-tail"'); | |
| 193 | expect(page.text).not.toContain("R7DEPLOYLINE"); | |
| 194 | } | |
| 195 | const mine = await get("/alice/square", "alice"); | |
| 196 | expect(mine.text).toContain('id="app-run-tail"'); | |
| 197 | expect(mine.text).toContain("R7DEPLOYLINE"); | |
| 198 | finishRun(app.db, deploy.id, { state: "ok", summary: "deploy" }); | |
| 199 | }); | |
| 200 | ||
| 201 | test("a running agent's tail is a writer's, on the app page", async () => { | |
| 202 | const run = createRun(app.db, { | |
| 203 | repoId: repoRow.id, | |
| 204 | kind: "agent", | |
| 205 | actorId: aliceId, | |
| 206 | reason: "assigned", | |
| 207 | }); | |
| 208 | claimRun(app.db, run.id); | |
| 209 | appendRunLog(dataDir, run.id, "R7AGENTLINE reading secrets.txt"); | |
| 210 | for (const who of [undefined, "mallory"]) { | |
| 211 | const page = await get("/alice/square", who); | |
| 212 | expect(page.status).toBe(200); | |
| 213 | expect(page.text).not.toContain('id="app-run-tail"'); | |
| 214 | expect(page.text).not.toContain("R7AGENTLINE"); | |
| 215 | } | |
| 216 | const mine = await get("/alice/square", "alice"); | |
| 217 | expect(mine.text).toContain('id="app-run-tail"'); | |
| 218 | expect(mine.text).toContain("R7AGENTLINE"); | |
| 219 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 220 | }); | |
| 221 | }); |
test/studio.test.ts +11 −12
| @@ -6051,7 +6051,9 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 6051 | 6051 | expect(page).not.toContain("$42.50"); |
| 6052 | 6052 | expect(page).not.toContain("7 turns"); |
| 6053 | 6053 | expect(page).not.toContain("keeper"); |
| 6054 | expect(page).toContain('data-who="the crew"'); | |
| 6054 | // The log box, which carried data-who, is not drawn for them (#1075). | |
| 6055 | expect(page).toContain("<title>the crew on #"); | |
| 6056 | expect(page).not.toContain('id="run-log"'); | |
| 6055 | 6057 | } |
| 6056 | 6058 | const ownerPage = await ( |
| 6057 | 6059 | await get(`/alice/beacon/runs/${run.id}`, ownerCookie) |
| @@ -6076,17 +6078,14 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 6076 | 6078 | ).json()) as { items: { costUsd: number }[] }; |
| 6077 | 6079 | expect(listAsStranger.items.every((r) => r.costUsd === 0)).toBe(true); |
| 6078 | 6080 | |
| 6079 | const tailAsStranger = await ( | |
| 6080 | await get( | |
| 6081 | `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`, | |
| 6082 | strangerCookie, | |
| 6083 | ) | |
| 6084 | ).text(); | |
| 6085 | expect(tailAsStranger).toContain("event: done"); | |
| 6086 | // The cost by its key, not its digits: a timestamp's seconds can read | |
| 6087 | // "42.5" too, and once did. | |
| 6088 | expect(tailAsStranger).not.toContain('"costUsd":42.5'); | |
| 6089 | expect(tailAsStranger).toContain('"costUsd":0'); | |
| 6081 | // The log is the writers' and crew's (#1075): a stranger's tail is | |
| 6082 | // refused, and carries neither the cost nor the transcript. | |
| 6083 | const tailAsStranger = await get( | |
| 6084 | `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`, | |
| 6085 | strangerCookie, | |
| 6086 | ); | |
| 6087 | expect(tailAsStranger.status).toBe(404); | |
| 6088 | expect(await tailAsStranger.text()).not.toContain("42.5"); | |
| 6090 | 6089 | const tailAsOwner = await ( |
| 6091 | 6090 | await get( |
| 6092 | 6091 | `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`, |
This says "writers and crew", and the code lets in writers only. Make the two agree. If check-run output is meant to be hidden from a pull's outside author too, say so here.