Public: anyone can read this; the crew can change it.

auth + default anon #18

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7-auth-default-anon → main 2 commits fork

  • Merged. scout/7-auth-default-anon was merged into main as 075ad85.

Review

Sign in to review this change.
2 files changed +363 −10
src/apps/scaffold.ts +350 −8
@@ -25,6 +25,7 @@ export function scaffoldFiles(input: ScaffoldInput): FileWrite[] {
2525 { path: ".gitignore", content: GITIGNORE },
2626 { path: "README.md", content: readme(title, description) },
2727 { path: "AGENTS.md", content: AGENTS_MD },
28 { path: "src/auth.ts", content: AUTH },
2829 { path: "src/server.ts", content: server(title, description) },
2930 { path: "src/server.test.ts", content: SERVER_TEST },
3031 { path: "src/tty.ts", content: tty(title) },
@@ -140,6 +141,167 @@ preview at its own address, deployed from your branch as you push.
140141 what is real versus simulated, \`forge.json\` says how to run it.
141142 `;
142143
144 const AUTH = `/**
145 * Auth, in one small file. The two ideas that matter:
146 *
147 * 1. Default allow anon. Nothing here forces a sign-in — the app works for
148 * a visitor with no cookie at all. Add a role check only where an action
149 * truly needs one; every route unchecked is anon by default.
150 * 2. Clear roles for RBAC. A session carries a role. Today there are two
151 * ("anon" — no session, the implicit default — and "admin"); add a third
152 * by extending the union below and the checks that read it, never by
153 * stringly-typed comparisons scattered through routes.
154 *
155 * An app is never claimed by whoever visits first: the first admin needs a
156 * secret only the operator holds — ADMIN_PASSWORD from the environment, or
157 * a one-time code this file generates and prints to the run log and the
158 * app's terminal. Sessions store a hash of their token, never the token
159 * itself, so a leaked database yields nothing usable.
160 */
161
162 import type { Database } from "bun:sqlite";
163
164 export type Role = "anon" | "admin";
165
166 const SESSION_COOKIE = "session";
167 const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
168
169 export function ensureAuthSchema(db: Database): void {
170 db.exec(
171 "CREATE TABLE IF NOT EXISTS session (token_hash TEXT PRIMARY KEY, role TEXT NOT NULL, expires_at TEXT NOT NULL)",
172 );
173 db.exec(
174 "CREATE TABLE IF NOT EXISTS admin_claim (id INTEGER PRIMARY KEY CHECK (id = 1), code TEXT NOT NULL, claimed INTEGER NOT NULL DEFAULT 0)",
175 );
176 }
177
178 function randomToken(bytes: number): string {
179 const raw = new Uint8Array(bytes);
180 crypto.getRandomValues(raw);
181 return Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join("");
182 }
183
184 function hash(token: string): string {
185 return new Bun.CryptoHasher("sha256").update(token).digest("hex");
186 }
187
188 // Equal-length, constant-time: a random token never leaks its prefix
189 // through how long a comparison takes to fail.
190 function timingSafeEqual(a: string, b: string): boolean {
191 if (a.length !== b.length) return false;
192 let diff = 0;
193 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
194 return diff === 0;
195 }
196
197 /**
198 * The secret that proves someone is the operator. ADMIN_PASSWORD from the
199 * environment wins and never expires or is marked used — it is the
200 * operator's own password, good for as many sign-ins as they like. With
201 * none set, a one-time code is generated on first boot and persisted
202 * (unclaimed) so every process — the server, the terminal — shows the same
203 * value, and using it once retires it for good.
204 */
205 export interface AdminSecret {
206 readonly value: string;
207 readonly fromEnv: boolean;
208 }
209
210 export function resolveAdminSecret(db: Database): AdminSecret {
211 const configured = process.env.ADMIN_PASSWORD?.trim();
212 if (configured) return { value: configured, fromEnv: true };
213 const row = db
214 .query<{ code: string; claimed: number }, []>(
215 "SELECT code, claimed FROM admin_claim WHERE id = 1",
216 )
217 .get();
218 if (row) return { value: row.claimed ? "" : row.code, fromEnv: false };
219 const code = randomToken(10);
220 db.query(
221 "INSERT INTO admin_claim (id, code, claimed) VALUES (1, ?1, 0)",
222 ).run(code);
223 return { value: code, fromEnv: false };
224 }
225
226 export function isAdminClaimed(db: Database): boolean {
227 if (process.env.ADMIN_PASSWORD?.trim()) return false; // never "claimed"; the password just keeps working
228 const row = db
229 .query<{ claimed: number }, []>("SELECT claimed FROM admin_claim WHERE id = 1")
230 .get();
231 return row?.claimed === 1;
232 }
233
234 export function verifyAdminSecret(db: Database, provided: string): boolean {
235 const secret = resolveAdminSecret(db);
236 if (!secret.value) return false;
237 if (!secret.fromEnv && isAdminClaimed(db)) return false;
238 return timingSafeEqual(provided, secret.value);
239 }
240
241 export function claimAdmin(db: Database): void {
242 db.query("UPDATE admin_claim SET claimed = 1 WHERE id = 1").run();
243 }
244
245 export function createSession(db: Database, role: Role): string {
246 const token = randomToken(24);
247 const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
248 db.query(
249 "INSERT INTO session (token_hash, role, expires_at) VALUES (?1, ?2, ?3)",
250 ).run(hash(token), role, expiresAt);
251 return token;
252 }
253
254 export function destroySession(db: Database, token: string): void {
255 db.query("DELETE FROM session WHERE token_hash = ?1").run(hash(token));
256 }
257
258 function cookieValue(request: Request, name: string): string | undefined {
259 const header = request.headers.get("cookie");
260 if (!header) return undefined;
261 for (const part of header.split(";")) {
262 const i = part.indexOf("=");
263 if (i === -1) continue;
264 if (part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
265 }
266 return undefined;
267 }
268
269 /** Anon unless a live, unexpired session says otherwise. That is the whole default-allow-anon rule. */
270 export function roleFromRequest(db: Database, request: Request): Role {
271 const token = cookieValue(request, SESSION_COOKIE);
272 if (!token) return "anon";
273 const row = db
274 .query<{ role: string; expires_at: string }, [string]>(
275 "SELECT role, expires_at FROM session WHERE token_hash = ?1",
276 )
277 .get(hash(token));
278 if (!row || row.expires_at <= new Date().toISOString()) return "anon";
279 return row.role === "admin" ? "admin" : "anon";
280 }
281
282 export function sessionToken(request: Request): string | undefined {
283 return cookieValue(request, SESSION_COOKIE);
284 }
285
286 export function setCookie(token: string, secure: boolean): string {
287 const attrs = [
288 \`\${SESSION_COOKIE}=\${token}\`,
289 "Path=/",
290 "HttpOnly",
291 "SameSite=Lax",
292 \`Max-Age=\${SESSION_TTL_MS / 1000}\`,
293 ];
294 if (secure) attrs.push("Secure");
295 return attrs.join("; ");
296 }
297
298 export function clearCookie(secure: boolean): string {
299 const attrs = [\`\${SESSION_COOKIE}=\`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"];
300 if (secure) attrs.push("Secure");
301 return attrs.join("; ");
302 }
303 `;
304
143305 const server = (title: string, description: string): string => `/**
144306 * ${title} — a small app with real data.
145307 *
@@ -151,6 +313,20 @@ const server = (title: string, description: string): string => `/**
151313 import { Database } from "bun:sqlite";
152314 import { mkdirSync } from "node:fs";
153315 import { join } from "node:path";
316 import {
317 claimAdmin,
318 clearCookie,
319 createSession,
320 destroySession,
321 ensureAuthSchema,
322 isAdminClaimed,
323 resolveAdminSecret,
324 roleFromRequest,
325 sessionToken,
326 setCookie,
327 verifyAdminSecret,
328 type Role,
329 } from "./auth.ts";
154330
155331 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
156332 mkdirSync(dataDir, { recursive: true });
@@ -159,11 +335,20 @@ db.exec("PRAGMA journal_mode = WAL");
159335 db.exec(
160336 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
161337 );
338 ensureAuthSchema(db);
162339
163340 const env = process.env.FORGE_APP_ENV ?? "local";
164341 const pull = process.env.FORGE_APP_PULL;
165342 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
166343
344 // Anon is the default for everything; admin is the one role above it, for
345 // the one thing anon should not do (delete). Printed once, here and in the
346 // terminal (src/tty.ts) — never on a page — so whoever is at the keyboard
347 // when the app first boots is the one who can claim it.
348 const adminSecret = resolveAdminSecret(db);
349 if (!adminSecret.fromEnv && !isAdminClaimed(db))
350 console.log(\`Admin claim code (use once, at /admin): \${adminSecret.value}\`);
351
167352 const escape = (s: string): string =>
168353 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
169354
@@ -246,7 +431,7 @@ async function readForm(request: Request): Promise<FormData | undefined> {
246431 }).formData();
247432 }
248433
249 function page(): string {
434 function page(role: Role): string {
250435 const notes = db
251436 .query<{ id: number; body: string; created_at: string }, []>(
252437 "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100",
@@ -254,6 +439,18 @@ function page(): string {
254439 .all();
255440 const where =
256441 env === "preview" ? \`preview of pull #\${pull ?? "?"}\` : env;
442 // Anon sees everything above; the one extra a session brings is a way to
443 // delete, so admin gets a button and a way to sign out.
444 const roleLine =
445 role === "admin"
446 ? \`admin · <form method="post" action="/admin/logout" class="inline"><button class="link">log out</button></form>\`
447 : \`<a href="/admin">admin</a>\`;
448 const noteItem = (n: { id: number; body: string; created_at: string }): string =>
449 \`<li><span>\${escape(n.body)}</span> <small>\${n.created_at}</small>\${
450 role === "admin"
451 ? \` <form method="post" action="/notes/delete" class="inline"><input type="hidden" name="id" value="\${n.id}"><button class="ghost" aria-label="Delete note">×</button></form>\`
452 : ""
453 }</li>\`;
257454 return \`<!doctype html>
258455 <meta charset="utf-8">
259456 <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -264,25 +461,63 @@ function page(): string {
264461 .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem;
265462 background: \${env === "production" ? "#16a34a" : "#2563eb"}; color: white; }
266463 form { display: flex; gap: .5rem; margin: 1.5rem 0; }
464 form.inline { display: inline; margin: 0; }
267465 input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; }
268466 button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; }
269 li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; }
467 button.link { background: none; color: inherit; text-decoration: underline; padding: 0; font-size: .8rem; }
468 button.ghost { background: none; color: #b91c1c; padding: 0 .4rem; font-size: 1rem; }
469 li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
470 li span { overflow-wrap: anywhere; }
270471 small { opacity: .6; }
271472 </style>
272473 <h1>${escape(title)}</h1>
273474 <p>${escape(description || "A new app on the forge.")}</p>
274 <p><span class="env">\${escape(where)}</span> <small>\${commit ? "@ " + commit : ""}</small></p>
475 <p><span class="env">\${escape(where)}</span> <small>\${commit ? "@ " + commit : ""} · \${roleLine}</small></p>
275476 <form method="post" action="/notes">
276477 <input name="body" placeholder="Write a note" required maxlength="500" autofocus>
277478 <button>Add</button>
278479 </form>
279 <ul>\${notes
280 .map((n) => \`<li>\${escape(n.body)} <small>\${n.created_at}</small></li>\`)
281 .join("")}</ul>
480 <ul>\${notes.map(noteItem).join("")}</ul>
282481 <p><small>\${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : \`\${notes.length} note\${notes.length === 1 ? "" : "s"} in \${escape(where)} data.\`}</small></p>
283482 \`;
284483 }
285484
485 function adminPage(role: Role, error?: string): string {
486 const claimed = isAdminClaimed(db);
487 const body =
488 role === "admin"
489 ? \`<h1>Signed in as admin</h1>
490 <p>Notes can now be deleted from the home page.</p>
491 <form method="post" action="/admin/logout"><button>Log out</button></form>\`
492 : \`<h1>Claim admin</h1>
493 <p>\${
494 claimed
495 ? "This app already has an admin. Ask them, or sign in from where it was first claimed."
496 : "The operator holds a secret: ADMIN_PASSWORD from the environment, or the one-time code printed to the run log and the app's terminal when it first booted."
497 }</p>
498 \${error ? \`<p class="error">\${escape(error)}</p>\` : ""}
499 <form method="post" action="/admin/claim">
500 <input name="secret" type="password" placeholder="Admin code" required autofocus>
501 <button>Claim</button>
502 </form>\`;
503 return \`<!doctype html>
504 <meta charset="utf-8">
505 <meta name="viewport" content="width=device-width, initial-scale=1">
506 <title>Admin — ${escape(title)}</title>
507 <style>
508 :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; }
509 body { max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
510 form { display: flex; gap: .5rem; margin: 1.5rem 0; }
511 input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; }
512 button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; }
513 .error { color: #b91c1c; }
514 a { color: inherit; }
515 </style>
516 <p><a href="/">← ${escape(title)}</a></p>
517 \${body}
518 \`;
519 }
520
286521 export const server = Bun.serve({
287522 port: Number(process.env.PORT ?? 8080),
288523 hostname: process.env.HOST ?? "127.0.0.1",
@@ -290,6 +525,10 @@ export const server = Bun.serve({
290525 const url = new URL(request.url);
291526 if (request.method === "POST" && !sameOrigin(request))
292527 return new Response("forbidden", { status: 403 });
528 // Read once per request; every route below is anon unless it says otherwise.
529 const role = roleFromRequest(db, request);
530 const secure = publicOrigin.startsWith("https:");
531
293532 if (request.method === "POST" && url.pathname === "/notes") {
294533 const form = await readForm(request);
295534 if (form === undefined)
@@ -298,9 +537,43 @@ export const server = Bun.serve({
298537 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body);
299538 return Response.redirect("/", 303);
300539 }
540 // Deleting is the one thing this app reserves for admin; everything else
541 // above stays open to anon, by default.
542 if (request.method === "POST" && url.pathname === "/notes/delete") {
543 if (role !== "admin") return new Response("forbidden", { status: 403, headers: SECURITY });
544 const form = await readForm(request);
545 if (form === undefined)
546 return new Response("too large", { status: 413, headers: SECURITY });
547 const id = Number(form.get("id"));
548 if (Number.isInteger(id)) db.query("DELETE FROM note WHERE id = ?1").run(id);
549 return Response.redirect("/", 303);
550 }
551 if (url.pathname === "/admin" && request.method === "GET") return html(adminPage(role));
552 if (request.method === "POST" && url.pathname === "/admin/claim") {
553 const form = await readForm(request);
554 if (form === undefined)
555 return new Response("too large", { status: 413, headers: SECURITY });
556 const secret = String(form.get("secret") ?? "");
557 if (!verifyAdminSecret(db, secret))
558 return html(adminPage("anon", "That code didn't match."), 403);
559 claimAdmin(db); // no-op when the secret is ADMIN_PASSWORD, which never expires
560 const token = createSession(db, "admin");
561 return new Response(null, {
562 status: 303,
563 headers: { location: "/admin", "set-cookie": setCookie(token, secure), ...SECURITY },
564 });
565 }
566 if (request.method === "POST" && url.pathname === "/admin/logout") {
567 const token = sessionToken(request);
568 if (token) destroySession(db, token);
569 return new Response(null, {
570 status: 303,
571 headers: { location: "/", "set-cookie": clearCookie(secure), ...SECURITY },
572 });
573 }
301574 // HEAD is GET without the body: a health check sees the app up.
302575 if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
303 const full = html(page());
576 const full = html(page(role));
304577 return request.method === "HEAD"
305578 ? new Response(null, { status: full.status, headers: full.headers })
306579 : full;
@@ -339,6 +612,7 @@ const tty = (title: string): string => `/**
339612 import { Database } from "bun:sqlite";
340613 import { mkdirSync } from "node:fs";
341614 import { join } from "node:path";
615 import { ensureAuthSchema, isAdminClaimed, resolveAdminSecret } from "./auth.ts";
342616
343617 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
344618 mkdirSync(dataDir, { recursive: true });
@@ -347,7 +621,13 @@ db.exec("PRAGMA journal_mode = WAL");
347621 db.exec(
348622 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
349623 );
624 ensureAuthSchema(db);
350625 const where = process.env.FORGE_APP_ENV === "preview" ? \`preview of #\${process.env.FORGE_APP_PULL ?? "?"}\` : (process.env.FORGE_APP_ENV ?? "local");
626 // Same code the server printed on boot (both read the same persisted row),
627 // shown here too — a terminal is a valid place to have first found it.
628 const adminSecret = resolveAdminSecret(db);
629 const claimLine =
630 !adminSecret.fromEnv && !isAdminClaimed(db) ? \`admin claim code: \${adminSecret.value}\` : undefined;
351631
352632 type Note = { id: number; body: string; created_at: string };
353633 const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all();
@@ -361,6 +641,7 @@ function draw(): void {
361641 const rows = notes();
362642 const lines: string[] = [];
363643 lines.push(\`\${esc}1m${title}\${esc}0m \${esc}2m\${where}\${esc}0m\`);
644 if (claimLine) lines.push(\`\${esc}2m\${claimLine} (use once, at /admin)\${esc}0m\`);
364645 lines.push("");
365646 if (rows.length === 0) lines.push(\`\${esc}2mNo notes yet. Press a to add one.\${esc}0m\`);
366647 rows.forEach((n, i) => {
@@ -412,8 +693,10 @@ import { join } from "node:path";
412693
413694 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
414695 process.env.PORT = "0";
696 process.env.ADMIN_PASSWORD = "test-admin-secret";
415697 const { server } = await import("./server.ts");
416698 const base = \`http://\${server.hostname}:\${server.port}\`;
699 let adminCookie = "";
417700
418701 test("serves the page", async () => {
419702 const response = await fetch(base + "/");
@@ -421,7 +704,7 @@ test("serves the page", async () => {
421704 expect(await response.text()).toContain("<h1>");
422705 });
423706
424 test("keeps a note", async () => {
707 test("an anonymous visitor can read and write, with no sign-in at all", async () => {
425708 const form = new FormData();
426709 form.set("body", "hello from a test");
427710 const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" });
@@ -435,6 +718,65 @@ test("stands on the security floor", async () => {
435718 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
436719 });
437720
721 test("anon cannot delete a note", async () => {
722 const form = new FormData();
723 form.set("id", "1");
724 const response = await fetch(base + "/notes/delete", { method: "POST", body: form, redirect: "manual" });
725 expect(response.status).toBe(403);
726 });
727
728 test("the wrong secret does not claim admin", async () => {
729 const form = new FormData();
730 form.set("secret", "not-it");
731 const response = await fetch(base + "/admin/claim", { method: "POST", body: form, redirect: "manual" });
732 expect(response.status).toBe(403);
733 expect(response.headers.get("set-cookie")).toBeNull();
734 });
735
736 test("the right secret claims admin", async () => {
737 const form = new FormData();
738 form.set("secret", "test-admin-secret");
739 const response = await fetch(base + "/admin/claim", { method: "POST", body: form, redirect: "manual" });
740 expect(response.status).toBe(303);
741 expect(response.headers.get("location")).toBe("/admin");
742 const cookie = response.headers.get("set-cookie") ?? "";
743 expect(cookie).toContain("HttpOnly");
744 adminCookie = cookie.split(";")[0] ?? "";
745 const page = await (await fetch(base + "/", { headers: { cookie: adminCookie } })).text();
746 expect(page).toContain("admin");
747 });
748
749 test("admin can delete a note, anon still cannot", async () => {
750 const form = new FormData();
751 form.set("body", "to be deleted");
752 await fetch(base + "/notes", { method: "POST", body: form });
753 const page = await (await fetch(base + "/", { headers: { cookie: adminCookie } })).text();
754 const id = page.match(/name="id" value="(\\d+)"/)?.[1];
755 expect(id).toBeDefined();
756
757 const asAnon = new FormData();
758 asAnon.set("id", id!);
759 expect((await fetch(base + "/notes/delete", { method: "POST", body: asAnon })).status).toBe(403);
760
761 const asAdmin = new FormData();
762 asAdmin.set("id", id!);
763 const response = await fetch(base + "/notes/delete", {
764 method: "POST",
765 body: asAdmin,
766 headers: { cookie: adminCookie },
767 redirect: "manual",
768 });
769 expect(response.status).toBe(303);
770 expect(await (await fetch(base + "/")).text()).not.toContain("to be deleted");
771 });
772
773 test("logging out ends the session", async () => {
774 const response = await fetch(base + "/admin/logout", { method: "POST", headers: { cookie: adminCookie }, redirect: "manual" });
775 expect(response.status).toBe(303);
776 const page = await (await fetch(base + "/admin", { headers: { cookie: adminCookie } })).text();
777 expect(page).toContain("Claim admin");
778 });
779
438780 test("a bug answers with the floor, not the runtime's page", async () => {
439781 // A percent-escape that is not one makes the search params throw.
440782 const response = await fetch(base + "/notes", {
test/crew.test.ts +13 −2
@@ -542,9 +542,20 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
542542 runs("agent").find((r) => r.reason === "assigned" && r.state === "ok"),
543543 );
544544 expect(built.summary).toMatch(/^opened pull #\d+/);
545 // The pull's own commit is checked in `pull-<number>`; the initial
546 // commit to the default branch (no CREW.md yet) is checked too, in
547 // `production`, and fails for its own reason (no such file) — so the
548 // pull's check is picked out by its environment, not just "failed".
549 const pullNumber = Number(built.summary.match(/#(\d+)/)?.[1]);
550 const pullChecks = () =>
551 listRuns(app.db, orchard.id, {
552 kind: "check",
553 env: `pull-${pullNumber}`,
554 limit: 50,
555 });
545556 // The forge checks the pull: no daffodils yet.
546557 const failed = await until(() =>
547 runs("check").find((r) => r.state === "failed"),
558 pullChecks().find((r) => r.state === "failed"),
548559 );
549560 expect(failed.summary).toBe("check failed: check exited with code 1");
550561 // …and the agent hears about it, with the log, and answers.
@@ -559,7 +570,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
559570 expect(prompt).toContain("check exited with code 1");
560571 expect(prompt).toContain("The end of the check log:");
561572 const passed = await until(() =>
562 runs("check").find((r) => r.state === "ok"),
573 pullChecks().find((r) => r.state === "ok"),
563574 );
564575 expect(passed.summary).toMatch(/^check passed/);
565576 }, 120_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs