diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd578d5b..51e6c3df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ New features, integrations, and notable improvements to Open-Inspect — newest first. +## July 26, 2026 + +**Better Auth browser authentication.** Browser sign-in now uses control-plane-owned Better Auth +sessions with GitHub and optional Google providers. The web app forwards an exact allowlist of +authentication routes through a signed proxy, and browser resource requests require both that signed +web-service channel and the browser session. Legacy browser tokens are retired during migration, so +existing users must sign in again after upgrading. + ## July 24, 2026 **Claude Opus 5.** Adds `claude-opus-5` to the model picker and integrations, with adaptive thinking diff --git a/packages/control-plane/src/auth/user/better-auth.ts b/packages/control-plane/src/auth/user/better-auth.ts index 893dcfda5..16e22df07 100644 --- a/packages/control-plane/src/auth/user/better-auth.ts +++ b/packages/control-plane/src/auth/user/better-auth.ts @@ -72,7 +72,14 @@ export function createUserAuth(config: UserAuthConfig) { }, } : {}), - ...(config.google ? { google: config.google } : {}), + ...(config.google + ? { + google: { + ...config.google, + disableIdTokenSignIn: true, + }, + } + : {}), }, user: { modelName: "auth_users", diff --git a/packages/control-plane/src/routes/session-prompt.ts b/packages/control-plane/src/routes/session-prompt.ts index d69a08d77..fcecf6902 100644 --- a/packages/control-plane/src/routes/session-prompt.ts +++ b/packages/control-plane/src/routes/session-prompt.ts @@ -1,5 +1,7 @@ import { MAX_SESSION_ATTACHMENTS_PER_MESSAGE, + callbackContextSchema, + sendPromptRequestSchema, sessionAttachmentReferencesSchema, type CallbackContext, type SessionAttachmentReference, @@ -45,30 +47,39 @@ async function handleSessionPrompt( const sessionId = match.groups?.id; if (!sessionId) return error("Session ID required"); - const body = (await request.json()) as { - content: string; - source?: string; - model?: string; - reasoningEffort?: string; - attachments?: unknown; - callbackContext?: CallbackContext; - }; + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return error("Invalid JSON body", 400); + } + + const enforcement = applyIdentityEnforcement(ctx, "prompt", rawBody); + if (enforcement.rejection) return enforcement.rejection; - if (!body.content) { + const bodyResult = sendPromptRequestSchema.safeParse(rawBody); + if (!bodyResult.success) { return error("content is required"); } - const enforcement = applyIdentityEnforcement(ctx, "prompt", body); - if (enforcement.rejection) return enforcement.rejection; + const body = bodyResult.data; const attachments = validateAttachments(body.attachments); if (attachments instanceof Response) return attachments; + let callbackContext: CallbackContext | undefined; + if (mayAttachCallbackContext(ctx) && body.callbackContext !== undefined) { + const callbackContextResult = callbackContextSchema.safeParse(body.callbackContext); + if (!callbackContextResult.success) { + return error("Invalid callbackContext", 400); + } + callbackContext = callbackContextResult.data; + } + // The author comes from the verified principal (user → canonical id, bot → // asserted actor); an actorless bot prompt is system-initiated and stays // anonymous. callbackContext is a completion notification channel — only // the bots that own callbacks may attach one. const authorId = enforcement.enforced.participantUserId ?? "anonymous"; - const callbackContext = mayAttachCallbackContext(ctx) ? body.callbackContext : undefined; if (callbackContext === undefined && body.callbackContext !== undefined) { logger.warn("Dropped callbackContext from unauthorized principal", { event: "identity.callback_context_dropped", diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index 8b917c811..f4e330516 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -339,7 +339,6 @@ export class SessionDO extends DurableObject { if (!this._presenceService) { this._presenceService = new PresenceService({ getAuthenticatedClients: () => this.wsManager.getAuthenticatedClients(), - getClientInfo: (ws) => this.getClientInfo(ws), messenger: this.messenger, send: (ws, msg) => this.safeSend(ws, msg), getSandboxSocket: () => this.wsManager.getSandboxSocket(), @@ -1192,17 +1191,22 @@ export class SessionDO extends DurableObject { return; } - switch (data.type) { - case "ping": - this.safeSend(ws, { type: "pong", timestamp: Date.now() }); - break; + if (data.type === "ping") { + this.safeSend(ws, { type: "pong", timestamp: Date.now() }); + return; + } - case "subscribe": - await this.handleSubscribe(ws, data); - break; + if (data.type === "subscribe") { + await this.handleSubscribe(ws, data); + return; + } + + const client = this.getClientInfo(ws); + if (!client) return; + switch (data.type) { case "prompt": - await this.handlePromptMessage(ws, data); + await this.handlePromptMessage(ws, client, data); break; case "stop": @@ -1214,11 +1218,11 @@ export class SessionDO extends DurableObject { break; case "fetch_history": - this.handleFetchHistory(ws, data); + this.handleFetchHistory(ws, client, data); break; case "presence": - this.presenceService.updatePresence(ws, data); + this.presenceService.updatePresence(client, data); break; } } catch (e) { @@ -1419,6 +1423,7 @@ export class SessionDO extends DurableObject { */ private async handlePromptMessage( ws: WebSocket, + client: ClientInfo, data: { content: string; model?: string; @@ -1426,16 +1431,6 @@ export class SessionDO extends DurableObject { attachments?: SessionAttachmentReference[]; } ): Promise { - const client = this.getClientInfo(ws); - if (!client) { - this.safeSend(ws, { - type: "error", - code: "NOT_SUBSCRIBED", - message: "Must subscribe first", - }); - return; - } - await this.messageQueue.handlePromptMessage(ws, client, data); } @@ -1444,18 +1439,9 @@ export class SessionDO extends DurableObject { */ private handleFetchHistory( ws: WebSocket, + client: ClientInfo, data: { cursor?: { timestamp: number; id: string }; limit?: number } ): void { - const client = this.getClientInfo(ws); - if (!client) { - this.safeSend(ws, { - type: "error", - code: "NOT_SUBSCRIBED", - message: "Must subscribe first", - }); - return; - } - // Validate cursor if ( !data.cursor || diff --git a/packages/control-plane/src/session/presence-service.test.ts b/packages/control-plane/src/session/presence-service.test.ts index f612e6ca4..c1e87795f 100644 --- a/packages/control-plane/src/session/presence-service.test.ts +++ b/packages/control-plane/src/session/presence-service.test.ts @@ -35,7 +35,6 @@ function createTestHarness() { const deps: PresenceServiceDeps = { getAuthenticatedClients: vi.fn(() => clients.values()), - getClientInfo: vi.fn(() => null), messenger: { broadcast: vi.fn(), sendToSandbox: vi.fn(() => true) }, send: vi.fn(() => true), getSandboxSocket: vi.fn(() => null), @@ -227,24 +226,13 @@ describe("PresenceService", () => { describe("updatePresence", () => { it("updates client status/lastSeen and broadcasts", () => { const client = createMockClient({ status: "active", lastSeen: 1000 }); - vi.mocked(harness.deps.getClientInfo).mockReturnValue(client); - const ws = {} as WebSocket; - harness.service.updatePresence(ws, { status: "idle" }); + harness.service.updatePresence(client, { status: "idle" }); expect(client.status).toBe("idle"); expect(client.lastSeen).toBeGreaterThan(1000); expect(harness.deps.messenger.broadcast).toHaveBeenCalled(); }); - - it("skips when client not found (no broadcast)", () => { - vi.mocked(harness.deps.getClientInfo).mockReturnValue(null); - const ws = {} as WebSocket; - - harness.service.updatePresence(ws, { status: "idle" }); - - expect(harness.deps.messenger.broadcast).not.toHaveBeenCalled(); - }); }); describe("handleTyping", () => { diff --git a/packages/control-plane/src/session/presence-service.ts b/packages/control-plane/src/session/presence-service.ts index d6ec34014..fccc1904f 100644 --- a/packages/control-plane/src/session/presence-service.ts +++ b/packages/control-plane/src/session/presence-service.ts @@ -18,7 +18,6 @@ import type { SessionMessenger } from "./messenger"; */ export interface PresenceServiceDeps { getAuthenticatedClients: () => IterableIterator; - getClientInfo: (ws: WebSocket) => ClientInfo | null; messenger: SessionMessenger; send: (ws: WebSocket, message: ServerMessage) => boolean; getSandboxSocket: () => WebSocket | null; @@ -82,15 +81,12 @@ export class PresenceService { * Update client presence status and broadcast. */ updatePresence( - ws: WebSocket, + client: ClientInfo, data: { status: "active" | "idle"; cursor?: { line: number; file: string } } ): void { - const client = this.deps.getClientInfo(ws); - if (client) { - client.status = data.status; - client.lastSeen = Date.now(); - this.broadcastPresence(); - } + client.status = data.status; + client.lastSeen = Date.now(); + this.broadcastPresence(); } /** diff --git a/packages/control-plane/test/integration/browser-auth.test.ts b/packages/control-plane/test/integration/browser-auth.test.ts index b49c7f577..6ce1075ed 100644 --- a/packages/control-plane/test/integration/browser-auth.test.ts +++ b/packages/control-plane/test/integration/browser-auth.test.ts @@ -1,7 +1,8 @@ import { env } from "cloudflare:test"; import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared"; import { getMigrations } from "better-auth/db/migration"; -import { describe, expect, it } from "vitest"; +import { verifyGoogleIdToken } from "better-auth/social-providers"; +import { describe, expect, it, vi } from "vitest"; import { SESSION_EXPIRES_IN_MS, SESSION_UPDATE_AGE_MS, @@ -14,6 +15,57 @@ const MS_PER_SECOND = 1000; const UNUSED_PROFILE_RESOLVER = async () => null; const UNUSED_USER_PROJECTION = { project: async () => {} }; +function encodeBase64Url(value: string | Uint8Array): string { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +async function createSignedGoogleIdToken(clientId: string) { + const keyId = "test-google-key"; + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + const issuedAt = Math.floor(Date.now() / MS_PER_SECOND); + const header = encodeBase64Url(JSON.stringify({ alg: "RS256", kid: keyId, typ: "JWT" })); + const payload = encodeBase64Url( + JSON.stringify({ + iss: "https://accounts.google.com", + aud: clientId, + sub: "direct-id-token-subject", + email: "direct-id-token@example.com", + email_verified: true, + name: "Direct ID Token User", + iat: issuedAt, + exp: issuedAt + 300, + }) + ); + const signingInput = `${header}.${payload}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keyPair.privateKey, + new TextEncoder().encode(signingInput) + ); + const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + return { + token: `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`, + publicKey: { + ...publicKey, + alg: "RS256", + kid: keyId, + use: "sig", + }, + }; +} + const EXPECTED_COLUMNS = { auth_users: [ ["id", "TEXT", 1, 1], @@ -152,6 +204,40 @@ describe("browser authentication", () => { expect(stateCookie?.toLowerCase()).not.toContain("domain="); }); + it("rejects social sign-in from an untrusted browser origin", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + [BROWSER_AUTH_CLIENT_IP_HEADER]: "203.0.113.74", + "Content-Type": "application/json", + Cookie: "__Secure-openinspect.session_token=invalid", + Origin: "https://attacker.example", + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(403); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + it("rate limits repeated browser sign-in attempts by the trusted client IP", async () => { const auth = createUserAuth({ database: env.DB, @@ -272,6 +358,69 @@ describe("browser authentication", () => { expect(providerUrl.searchParams.get("state")).toBeTruthy(); }); + it("rejects direct Google ID-token sign-in without creating authentication state", async () => { + const clientId = "google-client-id"; + const { token, publicKey } = await createSignedGoogleIdToken(clientId); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://www.googleapis.com/oauth2/v3/certs") { + return Response.json({ keys: [publicKey] }); + } + throw new Error(`Unexpected external request: ${url}`); + }); + + try { + await expect(verifyGoogleIdToken({ token, audience: clientId })).resolves.toMatchObject({ + sub: "direct-id-token-subject", + }); + + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + google: { + clientId, + clientSecret: "google-client-secret", + getUserInfo: async () => ({ + user: { + id: "direct-id-token-subject", + name: "Direct ID Token User", + email: "direct-id-token@example.com", + emailVerified: true, + }, + data: null, + }), + }, + }); + + const response = await auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + [BROWSER_AUTH_CLIENT_IP_HEADER]: "203.0.113.75", + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "google", + callbackURL: "/", + idToken: { token }, + }), + }) + ); + + expect(response.status).toBe(401); + expect(response.headers.get("set-cookie")).toBeNull(); + const sessionCount = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM auth_sessions" + ).first<{ count: number }>(); + expect(sessionCount?.count).toBe(0); + } finally { + fetchSpy.mockRestore(); + } + }); + it("uses canonical ids and converts millisecond durations at the library boundary", () => { const auth = createTestAuth(); const generateId = auth.options.advanced?.database?.generateId; diff --git a/packages/control-plane/test/integration/stop-execution.test.ts b/packages/control-plane/test/integration/stop-execution.test.ts index f86de578f..e656fcd09 100644 --- a/packages/control-plane/test/integration/stop-execution.test.ts +++ b/packages/control-plane/test/integration/stop-execution.test.ts @@ -375,4 +375,41 @@ describe("POST /internal/stop", () => { clientWs.close(); if (sandboxWs) sandboxWs.close(); }); + + it("does not stop execution before the client subscribes", async () => { + const name = `ws-stop-client-unsubscribed-${Date.now()}`; + const { stub } = await initNamedSession(name); + + const participants = await queryDO<{ id: string }>( + stub, + "SELECT id FROM participants WHERE user_id = 'user-1'" + ); + const participantId = participants[0].id; + + const msgId = "msg-ws-stop-unsubscribed"; + await seedMessage(stub, { + id: msgId, + authorId: participantId, + content: "Must remain in progress", + source: "web", + status: "processing", + createdAt: Date.now() - 1000, + startedAt: Date.now() - 500, + }); + + const { ws } = await openClientWs(name); + const closed = new Promise<{ code: number }>((resolve) => { + ws.addEventListener("close", (event) => resolve({ code: event.code })); + }); + + ws.send(JSON.stringify({ type: "stop" })); + + await expect(closed).resolves.toEqual({ code: 4002 }); + const messages = await queryDO<{ status: string }>( + stub, + "SELECT status FROM messages WHERE id = ?", + msgId + ); + expect(messages[0].status).toBe("processing"); + }); }); diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts index 95210f74c..3fd6e778a 100644 --- a/packages/control-plane/test/integration/websocket-client.test.ts +++ b/packages/control-plane/test/integration/websocket-client.test.ts @@ -43,6 +43,20 @@ describe("Client WebSocket (via SELF.fetch)", () => { expect(rows[0].count).toBe(0); }); + it("rejects typing before subscribing", async () => { + const name = `ws-client-nosub-typing-${Date.now()}`; + await initNamedSession(name); + + const { ws } = await openClientWs(name); + const closed = new Promise<{ code: number }>((resolve) => { + ws.addEventListener("close", (event) => resolve({ code: event.code })); + }); + + ws.send(JSON.stringify({ type: "typing" })); + + await expect(closed).resolves.toEqual({ code: 4002 }); + }); + it("subscribe with valid token sends subscribed + state", async () => { const name = `ws-client-sub-${Date.now()}`; await initNamedSession(name, { repoOwner: "acme", repoName: "web-app" }); diff --git a/packages/control-plane/vitest.integration.config.ts b/packages/control-plane/vitest.integration.config.ts index a3bb08c87..78c72f03f 100644 --- a/packages/control-plane/vitest.integration.config.ts +++ b/packages/control-plane/vitest.integration.config.ts @@ -83,21 +83,27 @@ export default defineConfig({ include: ["test/integration/**/*.test.ts"], setupFiles: ["test/integration/apply-migrations.ts"], onUnhandledError(error) { - // Better Auth implements OAuth callback redirects as thrown APIError - // values. Its handler catches and converts them to the expected 3xx - // response, but the Workers pool reports the intermediate rejection as - // unhandled. Filter only that library-owned redirect control flow; every - // other unhandled error remains fatal. + // Better Auth implements redirects and invalid-token responses as thrown + // APIError values. Its handler catches and converts them to HTTP responses, + // but the Workers pool reports the intermediate rejection as unhandled. const betterAuthStack = "errorStack" in error && typeof error.errorStack === "string" ? error.errorStack : error.stack; + const betterAuthErrorCode = + "body" in error && + typeof error.body === "object" && + error.body !== null && + "code" in error.body && + typeof error.body.code === "string" + ? error.body.code + : null; if ( error.name === "APIError" && "statusCode" in error && typeof error.statusCode === "number" && - error.statusCode >= 300 && - error.statusCode < 400 && + ((error.statusCode >= 300 && error.statusCode < 400) || + (error.statusCode === 401 && betterAuthErrorCode === "INVALID_TOKEN")) && betterAuthStack?.includes("/better-auth/dist/api/routes/") ) { return false; diff --git a/packages/github-bot/README.md b/packages/github-bot/README.md index 271106b56..a16aeab97 100644 --- a/packages/github-bot/README.md +++ b/packages/github-bot/README.md @@ -123,10 +123,12 @@ All events are processed asynchronously via `executionCtx.waitUntil()`. The webh **Pull Request Opened (Auto-Review):** 1. Check `pull_request.draft` — skip draft PRs -2. Check `pull_request.user.login !== GITHUB_BOT_USERNAME` — prevent loops on bot-created PRs +2. Apply the configured trigger-user gate — bot-created PRs are reviewed when the bot login is + explicitly listed in `allowedTriggerUsers` 3. Post eyes reaction on the PR (fire-and-forget) 4. Create session via control plane -5. Send code review prompt (includes PR metadata + `gh` CLI instructions) +5. Send code review prompt (includes PR metadata + `gh` CLI instructions). Reviews of the bot's own + PRs use `COMMENT`, because GitHub does not allow pull request authors to approve their own PRs. **Review Requested (compatibility path):** diff --git a/packages/github-bot/src/handlers.ts b/packages/github-bot/src/handlers.ts index ff07e458e..859dabb27 100644 --- a/packages/github-bot/src/handlers.ts +++ b/packages/github-bot/src/handlers.ts @@ -291,11 +291,6 @@ export async function handlePullRequestOpened( return { outcome: "skipped", skip_reason: "draft_pr" }; } - if (pr.user.login === env.GITHUB_BOT_USERNAME) { - log.debug("handler.self_pr_ignored", { trace_id: traceId, pull_number: pr.number }); - return { outcome: "skipped", skip_reason: "self_pr" }; - } - const config = await getGitHubConfig(env, repoFullName, log); if (config.enabledRepos !== null && !config.enabledRepos.includes(repoFullName)) { @@ -360,6 +355,7 @@ export async function handlePullRequestOpened( head: pr.head.ref, isPublic: !repo.private, codeReviewInstructions: config.codeReviewInstructions, + isSelfReview: pr.user.login.toLowerCase() === env.GITHUB_BOT_USERNAME.toLowerCase(), }); const messageId = await sendPrompt(env, traceId, sessionId, { diff --git a/packages/github-bot/src/prompts.ts b/packages/github-bot/src/prompts.ts index 2d994a535..92fc22e13 100644 --- a/packages/github-bot/src/prompts.ts +++ b/packages/github-bot/src/prompts.ts @@ -45,9 +45,25 @@ export function buildCodeReviewPrompt(params: { head: string; isPublic: boolean; codeReviewInstructions?: string | null; + isSelfReview?: boolean; }): string { - const { owner, repo, number, title, body, author, base, head, isPublic, codeReviewInstructions } = - params; + const { + owner, + repo, + number, + title, + body, + author, + base, + head, + isPublic, + codeReviewInstructions, + isSelfReview = false, + } = params; + const reviewEvent = isSelfReview ? "COMMENT" : "COMMENT|APPROVE|REQUEST_CHANGES"; + const reviewEventGuidance = isSelfReview + ? "Use COMMENT because GitHub does not allow pull request authors to approve their own PRs." + : "Use APPROVE if the code looks good, REQUEST_CHANGES if changes are needed,\n or COMMENT for general feedback."; const prTitleBlock = buildUntrustedUserContentBlock({ source: "github_pr_title", @@ -96,10 +112,9 @@ ${prDescriptionBlock} gh api repos/${owner}/${repo}/pulls/${number}/reviews \\ --method POST \\ -f body="" \\ - -f event="COMMENT|APPROVE|REQUEST_CHANGES" + -f event="${reviewEvent}" - Use APPROVE if the code looks good, REQUEST_CHANGES if changes are needed, - or COMMENT for general feedback. + ${reviewEventGuidance} 5. For inline comments on specific files: diff --git a/packages/github-bot/test/handlers.test.ts b/packages/github-bot/test/handlers.test.ts index 8676fd2c5..870853afb 100644 --- a/packages/github-bot/test/handlers.test.ts +++ b/packages/github-bot/test/handlers.test.ts @@ -273,7 +273,11 @@ describe("handlePullRequestOpened", () => { expect(log.debug).toHaveBeenCalledWith("handler.draft_pr_skipped", expect.anything()); }); - it("returns early if PR is from the bot (loop prevention)", async () => { + it("reviews a bot-authored PR when the bot is an allowed trigger user", async () => { + vi.mocked(getGitHubConfig).mockResolvedValue({ + ...defaultConfig, + allowedTriggerUsers: ["test-bot[bot]"], + }); const env = createMockEnv(); const log = createMockLogger(); const payload: PullRequestOpenedPayload = { @@ -282,13 +286,50 @@ describe("handlePullRequestOpened", () => { ...pullRequestOpenedPayload.pull_request, user: { login: "test-bot[bot]" }, }, + sender: { + login: "test-bot[bot]", + id: 1004, + avatar_url: "https://avatars.githubusercontent.com/u/1004", + }, }; const result = await handlePullRequestOpened(env, log, payload, "trace-0"); - expect(result).toEqual({ outcome: "skipped", skip_reason: "self_pr" }); + expect(result).toEqual({ + outcome: "processed", + session_id: "session-123", + message_id: "msg-456", + handler_action: "auto_review", + }); + expect(sessionCreateBody(getControlPlaneFetch(env)).scmLogin).toBe("test-bot[bot]"); + expect(promptSendBody(getControlPlaneFetch(env)).content).toContain('-f event="COMMENT"'); + }); + + it("rejects a bot-authored PR when the bot is not an allowed trigger user", async () => { + vi.mocked(getGitHubConfig).mockResolvedValue({ + ...defaultConfig, + allowedTriggerUsers: ["alice"], + }); + const env = createMockEnv(); + const log = createMockLogger(); + const payload: PullRequestOpenedPayload = { + ...pullRequestOpenedPayload, + pull_request: { + ...pullRequestOpenedPayload.pull_request, + user: { login: "test-bot[bot]" }, + }, + sender: { + login: "test-bot[bot]", + id: 1004, + avatar_url: "https://avatars.githubusercontent.com/u/1004", + }, + }; + + const result = await handlePullRequestOpened(env, log, payload, "trace-0"); + + expect(result).toEqual({ outcome: "skipped", skip_reason: "sender_not_allowed" }); expect(generateInstallationToken).not.toHaveBeenCalled(); - expect(log.debug).toHaveBeenCalledWith("handler.self_pr_ignored", expect.anything()); + expect(getControlPlaneFetch(env)).not.toHaveBeenCalled(); }); it("returns early when autoReviewOnOpen is false", async () => { diff --git a/packages/github-bot/test/prompts.test.ts b/packages/github-bot/test/prompts.test.ts index b3b5cc302..87c8b6fab 100644 --- a/packages/github-bot/test/prompts.test.ts +++ b/packages/github-bot/test/prompts.test.ts @@ -62,6 +62,13 @@ describe("buildCodeReviewPrompt", () => { expect(prompt).toContain("repos/acme/widgets/pulls/42/comments"); }); + it("limits self-reviews to comments", () => { + const prompt = buildCodeReviewPrompt({ ...baseParams, isSelfReview: true }); + expect(prompt).toContain('-f event="COMMENT"'); + expect(prompt).toContain("GitHub does not allow pull request authors to approve their own PRs"); + expect(prompt).not.toContain("COMMENT|APPROVE|REQUEST_CHANGES"); + }); + it("includes custom instructions section when codeReviewInstructions provided", () => { const prompt = buildCodeReviewPrompt({ ...baseParams, diff --git a/packages/linear-bot/src/utils/linear-client.test.ts b/packages/linear-bot/src/utils/linear-client.test.ts index 13ddd0dda..b62edb6fd 100644 --- a/packages/linear-bot/src/utils/linear-client.test.ts +++ b/packages/linear-bot/src/utils/linear-client.test.ts @@ -4,6 +4,7 @@ import { fetchIssueDetails, fetchUser, getRepoSuggestions, + postIssueComment, } from "./linear-client"; import type { LinearApiClient } from "./linear-client"; @@ -200,3 +201,65 @@ describe("emitAgentActivity", () => { ).resolves.toBe(false); }); }); + +describe("postIssueComment", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns success from a valid comment mutation response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { commentCreate: { success: true } } }), + }) + ); + + await expect(postIssueComment("token", "issue-1", "hello")).resolves.toEqual({ + success: true, + }); + }); + + it("returns false when the nullable comment mutation result is absent", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { commentCreate: null } }), + }) + ); + + await expect(postIssueComment("token", "issue-1", "hello")).resolves.toEqual({ + success: false, + }); + }); + + it("returns false when the comment mutation response is malformed", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { commentCreate: { success: "yes" } } }), + }) + ); + + await expect(postIssueComment("token", "issue-1", "hello")).resolves.toEqual({ + success: false, + }); + }); + + it("returns false when the comment mutation response is not valid JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.reject(new SyntaxError("Unexpected token")), + }) + ); + + await expect(postIssueComment("token", "issue-1", "hello")).resolves.toEqual({ + success: false, + }); + }); +}); diff --git a/packages/linear-bot/src/utils/linear-client.ts b/packages/linear-bot/src/utils/linear-client.ts index de84149a7..d2ab186d1 100644 --- a/packages/linear-bot/src/utils/linear-client.ts +++ b/packages/linear-bot/src/utils/linear-client.ts @@ -17,6 +17,7 @@ import { LINEAR_CLIENT_CREDENTIALS_SCOPE, LinearAuthError, } from "./linear-credentials"; +import { z } from "zod"; export { completeLinearOAuthInstallation, @@ -30,6 +31,20 @@ const log = createLogger("linear-client"); const LINEAR_API_URL = "https://api.linear.app/graphql"; +const linearCommentCreateResponseSchema = z.object({ + data: z + .object({ + commentCreate: z + .object({ + success: z.boolean(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), +}); + // ─── OAuth Helpers ─────────────────────────────────────────────────────────── export function buildOAuthAuthorizeUrl(env: Env): string { @@ -385,8 +400,9 @@ export async function postIssueComment( }); if (!response.ok) return { success: false }; - const result = (await response.json()) as { - data?: { commentCreate?: { success: boolean } }; - }; - return { success: result.data?.commentCreate?.success ?? false }; + const result = linearCommentCreateResponseSchema.safeParse( + await response.json().catch(() => null) + ); + if (!result.success) return { success: false }; + return { success: result.data.data?.commentCreate?.success ?? false }; } diff --git a/packages/shared/src/types/boundary-schemas.test.ts b/packages/shared/src/types/boundary-schemas.test.ts index 743ae025e..f7e8dc33b 100644 --- a/packages/shared/src/types/boundary-schemas.test.ts +++ b/packages/shared/src/types/boundary-schemas.test.ts @@ -5,10 +5,12 @@ import { clientMessageSchema, createSessionResponseSchema, createSessionRequestSchema, + callbackContextSchema, MAX_AUTOMATION_REPOSITORIES, normalizeOptionalRepositoryPair, RepositoryPairValidationError, sandboxEventSchema, + sendPromptRequestSchema, serverMessageSchema, sendPromptResponseSchema, spawnChildSessionRequestSchema, @@ -127,6 +129,82 @@ describe("boundary schemas", () => { }); }); + describe("sendPromptRequestSchema", () => { + it("parses a valid prompt request with a Slack callback context", () => { + const result = sendPromptRequestSchema.safeParse({ + content: "Investigate the failure", + source: "slack", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "high", + attachments: [{ attachmentId: "att-1", name: "screenshot.png" }], + callbackContext: { + source: "slack", + channel: "C123", + threadTs: "1710000000.000100", + repoFullName: "open-inspect/background-agents", + model: "anthropic/claude-sonnet-4-6", + reactionMessageTs: "1710000000.000200", + }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects a malformed prompt request", () => { + expect(sendPromptRequestSchema.safeParse({ content: 123 }).success).toBe(false); + expect(sendPromptRequestSchema.safeParse({ source: "web" }).success).toBe(false); + expect(sendPromptRequestSchema.safeParse({ content: "" }).success).toBe(false); + }); + }); + + describe("callbackContextSchema", () => { + it("parses valid callback contexts", () => { + expect( + callbackContextSchema.safeParse({ + source: "slack", + channel: "C123", + threadTs: "1710000000.000100", + repoFullName: "open-inspect/background-agents", + model: "anthropic/claude-sonnet-4-6", + }).success + ).toBe(true); + expect( + callbackContextSchema.safeParse({ + source: "linear", + issueId: "issue-1", + issueIdentifier: "OI-123", + issueUrl: "https://linear.app/open-inspect/issue/OI-123/test", + repoFullName: "open-inspect/background-agents", + model: "anthropic/claude-sonnet-4-6", + transitionIssueOnStart: false, + }).success + ).toBe(true); + expect( + callbackContextSchema.safeParse({ + source: "automation", + automationId: "automation-1", + runId: "run-1", + automationName: "Nightly sweep", + }).success + ).toBe(true); + }); + + it("rejects malformed or partial callback contexts", () => { + expect(callbackContextSchema.safeParse({ source: "slack", channel: "C123" }).success).toBe( + false + ); + expect( + callbackContextSchema.safeParse({ + source: "automation", + automationId: "automation-1", + runId: null, + automationName: "Nightly sweep", + }).success + ).toBe(false); + expect(callbackContextSchema.safeParse({ source: "github" }).success).toBe(false); + }); + }); + describe("sandboxEventSchema", () => { it("parses a valid tool call event", () => { const result = sandboxEventSchema.safeParse({ diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index f02559a47..adeda8663 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -62,6 +62,13 @@ export type { RepositoryPair, } from "./repositories"; +export { + installationRepositorySchema, + repoMetadataSchema, + enrichedRepositorySchema, + repoConfigSchema, + controlPlaneReposResponseSchema, +} from "./repository-catalog"; export type { InstallationRepository, RepoMetadata, @@ -151,8 +158,12 @@ export type { } from "./session-diffs"; export { + automationCallbackContextSchema, + callbackContextSchema, linearCallbackContextSchema, linearStartCallbackSchema, + sendPromptRequestSchema, + slackCallbackContextSchema, createSessionRequestSchema, createSessionInputSchema, createMediaArtifactRequestSchema, @@ -169,6 +180,7 @@ export type { LinearStartCallback, AutomationCallbackContext, CallbackContext, + SendPromptRequest, CreateSessionRequest, CreateSessionInput, CreateMediaArtifactRequest, diff --git a/packages/shared/src/types/repository-catalog.test.ts b/packages/shared/src/types/repository-catalog.test.ts new file mode 100644 index 000000000..3494e7886 --- /dev/null +++ b/packages/shared/src/types/repository-catalog.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { controlPlaneReposResponseSchema, repoConfigSchema } from "./repository-catalog"; + +describe("controlPlaneReposResponseSchema", () => { + it("parses a valid control-plane repos response with nullable fields", () => { + const result = controlPlaneReposResponseSchema.safeParse({ + repos: [ + { + id: 123, + owner: "Open-Inspect", + name: "Background-Agents", + fullName: "Open-Inspect/Background-Agents", + description: null, + private: true, + defaultBranch: "main", + archived: false, + language: null, + metadata: { + description: "Slack-facing description", + aliases: ["agents"], + channelAssociations: ["C123"], + keywords: ["classifier"], + defaultEnvironmentId: "env_123", + }, + }, + ], + cached: false, + cachedAt: "2026-07-27T00:00:00.000Z", + }); + + expect(result.success).toBe(true); + }); + + it("rejects malformed repo entries", () => { + const result = controlPlaneReposResponseSchema.safeParse({ + repos: [{ owner: "Open-Inspect", name: "Background-Agents" }], + cached: false, + cachedAt: "2026-07-27T00:00:00.000Z", + }); + + expect(result.success).toBe(false); + }); + + it("rejects repo entries missing canonical repository fields", () => { + const result = controlPlaneReposResponseSchema.safeParse({ + repos: [ + { + owner: "Open-Inspect", + name: "Background-Agents", + description: null, + private: true, + defaultBranch: "main", + }, + ], + cached: false, + cachedAt: "2026-07-27T00:00:00.000Z", + }); + + expect(result.success).toBe(false); + }); + + it("rejects responses missing cache metadata", () => { + const result = controlPlaneReposResponseSchema.safeParse({ + repos: [ + { + id: 123, + owner: "Open-Inspect", + name: "Background-Agents", + fullName: "Open-Inspect/Background-Agents", + description: null, + private: true, + defaultBranch: "main", + archived: false, + }, + ], + }); + + expect(result.success).toBe(false); + }); +}); + +describe("repoConfigSchema", () => { + it("parses cached repo config values with nullable optional fields", () => { + const result = repoConfigSchema.safeParse({ + id: "open-inspect/background-agents", + owner: "open-inspect", + name: "background-agents", + fullName: "open-inspect/background-agents", + displayName: "Background-Agents", + description: "Cached repo", + defaultBranch: "main", + private: false, + language: null, + }); + + expect(result.success).toBe(true); + }); + + it("rejects malformed cached repo config values", () => { + const result = repoConfigSchema.safeParse({ + id: "open-inspect/background-agents", + owner: "open-inspect", + private: false, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/shared/src/types/repository-catalog.ts b/packages/shared/src/types/repository-catalog.ts index 39e92abf9..5763c52b2 100644 --- a/packages/shared/src/types/repository-catalog.ts +++ b/packages/shared/src/types/repository-catalog.ts @@ -1,60 +1,69 @@ import type { ConfidenceLevel } from "./statuses"; +import { z } from "zod"; -// Repository types for GitHub App installation -export interface InstallationRepository { - id: number; - owner: string; - name: string; - fullName: string; - description: string | null; - private: boolean; - defaultBranch: string; - archived: boolean; - language?: string | null; - topics?: string[]; -} +export const installationRepositorySchema = z.object({ + id: z.number(), + owner: z.string(), + name: z.string(), + fullName: z.string(), + description: z.string().nullable(), + private: z.boolean(), + defaultBranch: z.string(), + archived: z.boolean(), + language: z.string().nullable().optional(), + topics: z.array(z.string()).optional(), +}); + +export type InstallationRepository = z.infer; -export interface RepoMetadata { - description?: string; - aliases?: string[]; - channelAssociations?: string[]; - keywords?: string[]; +export const repoMetadataSchema = z.object({ + description: z.string().optional(), + aliases: z.array(z.string()).optional(), + channelAssociations: z.array(z.string()).optional(), + keywords: z.array(z.string()).optional(), /** * Environment opened by GitHub-bot sessions triggered from this repo * (design §13.2). The bot falls back to a repo-bound session when the * environment no longer exists or no longer contains this repository. */ - defaultEnvironmentId?: string; -} + defaultEnvironmentId: z.string().optional(), +}); -export interface EnrichedRepository extends InstallationRepository { - metadata?: RepoMetadata; -} +export type RepoMetadata = z.infer; -// Bot package shared types -export interface RepoConfig { - id: string; - owner: string; - name: string; - fullName: string; - displayName: string; - description: string; - defaultBranch: string; - private: boolean; - language?: string | null; - topics?: string[]; - aliases?: string[]; - keywords?: string[]; - channelAssociations?: string[]; -} +export const enrichedRepositorySchema = installationRepositorySchema.extend({ + metadata: repoMetadataSchema.optional(), +}); + +export type EnrichedRepository = z.infer; + +export const repoConfigSchema = z.object({ + id: z.string(), + owner: z.string(), + name: z.string(), + fullName: z.string(), + displayName: z.string(), + description: z.string(), + defaultBranch: z.string(), + private: z.boolean(), + language: z.string().nullable().optional(), + topics: z.array(z.string()).optional(), + aliases: z.array(z.string()).optional(), + keywords: z.array(z.string()).optional(), + channelAssociations: z.array(z.string()).optional(), +}); + +export type RepoConfig = z.infer; export type ControlPlaneRepo = EnrichedRepository; -export interface ControlPlaneReposResponse { - repos: ControlPlaneRepo[]; - cached: boolean; - cachedAt: string; -} +export const controlPlaneReposResponseSchema = z.object({ + repos: z.array(enrichedRepositorySchema), + cached: z.boolean(), + cachedAt: z.string(), +}); + +export type ControlPlaneReposResponse = z.infer; export interface ClassificationResult { repo: RepoConfig | null; diff --git a/packages/shared/src/types/session-api.ts b/packages/shared/src/types/session-api.ts index d7f3550be..7f8c2beca 100644 --- a/packages/shared/src/types/session-api.ts +++ b/packages/shared/src/types/session-api.ts @@ -13,17 +13,20 @@ export interface UserPreferences { updatedAt: number; } -export interface SlackCallbackContext { - source: "slack"; - channel: string; - threadTs: string; - repoFullName: string; - model: string; - reasoningEffort?: string; - reactionMessageTs?: string; -} - const nonEmptyStringSchema = z.string().trim().min(1); + +export const slackCallbackContextSchema = z.object({ + source: z.literal("slack"), + channel: z.string(), + threadTs: z.string(), + repoFullName: z.string(), + model: z.string(), + reasoningEffort: z.string().optional(), + reactionMessageTs: z.string().optional(), +}); + +export type SlackCallbackContext = z.infer; + const linearCallbackContextBaseSchema = z.strictObject({ source: z.literal("linear"), issueId: nonEmptyStringSchema, @@ -63,17 +66,33 @@ export const linearStartCallbackSchema = z.strictObject({ export type LinearStartCallback = z.infer; -export interface AutomationCallbackContext { - source: "automation"; - automationId: string; - runId: string; - automationName: string; -} +export const automationCallbackContextSchema = z.object({ + source: z.literal("automation"), + automationId: z.string(), + runId: z.string(), + automationName: z.string(), +}); + +export type AutomationCallbackContext = z.infer; + +export const callbackContextSchema = z.union([ + slackCallbackContextSchema, + linearCallbackContextSchema, + automationCallbackContextSchema, +]); + +export type CallbackContext = z.infer; + +export const sendPromptRequestSchema = z.object({ + content: z.string().min(1), + source: z.string().optional(), + model: z.string().optional(), + reasoningEffort: z.string().optional(), + attachments: z.unknown().optional(), + callbackContext: z.unknown().optional(), +}); -export type CallbackContext = - | SlackCallbackContext - | LinearCallbackContext - | AutomationCallbackContext; +export type SendPromptRequest = z.infer; function hasRepositoryIdentifier(value: string | null | undefined): boolean { return typeof value === "string" && value.trim().length > 0; diff --git a/packages/slack-bot/src/classifier/repos.test.ts b/packages/slack-bot/src/classifier/repos.test.ts index 5be9ee5b6..a71efcfb2 100644 --- a/packages/slack-bot/src/classifier/repos.test.ts +++ b/packages/slack-bot/src/classifier/repos.test.ts @@ -177,6 +177,35 @@ describe("getAvailableRepos", () => { expect(env.SLACK_KV.get).toHaveBeenCalledWith("repos:cache", "json"); }); + it("falls back when the control-plane repository response is malformed", async () => { + const env = makeEnv( + jsonResponse({ + repos: [{ owner: "Open-Inspect", name: "Background-Agents" }], + cached: false, + cachedAt: new Date().toISOString(), + }) + ); + + await expect(getAvailableRepos(env, "trace-3")).resolves.toEqual([]); + expect(env.SLACK_KV.put).not.toHaveBeenCalled(); + }); + + it("rejects malformed cached repositories on the fallback path", async () => { + const env = { + SLACK_KV: { + get: vi.fn().mockResolvedValue([{ id: "acme/web", owner: "acme", private: false }]), + put: vi.fn().mockResolvedValue(undefined), + }, + CONTROL_PLANE: { + fetch: vi.fn().mockResolvedValue(new Response("error", { status: 503 })), + }, + SERVICE_AUTH_SECRET: "test-secret", + } as unknown as Env; + + await expect(getAvailableRepos(env, "trace-4")).resolves.toEqual([]); + expect(env.SLACK_KV.get).toHaveBeenCalledWith("repos:cache", "json"); + }); + it("uses the in-memory cache after a successful fetch", async () => { const env = makeEnv( jsonResponse({ diff --git a/packages/slack-bot/src/classifier/repos.ts b/packages/slack-bot/src/classifier/repos.ts index a6372aba7..bd3671971 100644 --- a/packages/slack-bot/src/classifier/repos.ts +++ b/packages/slack-bot/src/classifier/repos.ts @@ -6,11 +6,13 @@ * GitHub App installation to get the list of accessible repositories. */ -import type { Env, RepoConfig, ControlPlaneRepo, ControlPlaneReposResponse } from "../types"; +import type { Env, RepoConfig } from "../types"; import { normalizeRepoId } from "../utils/repo"; import { + controlPlaneReposResponseSchema, createKvCacheStore, normalizeRoutingRules, + repoConfigSchema, type SlackGlobalConfig, type SlackRoutingRule, } from "@open-inspect/shared"; @@ -48,11 +50,13 @@ const watchedChannelsResponseSchema = z.object({ channels: watchedChannelsSchema.optional(), }); +type ParsedControlPlaneRepo = z.infer["repos"][number]; + /** * Convert a control plane repo to a RepoConfig. * Normalizes identifiers to lowercase for consistent comparison. */ -function toRepoConfig(repo: ControlPlaneRepo): RepoConfig { +function toRepoConfig(repo: ParsedControlPlaneRepo): RepoConfig { const normalizedOwner = repo.owner.toLowerCase(); const normalizedName = repo.name.toLowerCase(); @@ -102,8 +106,17 @@ export async function getAvailableRepos(env: Env, traceId?: string): Promise { try { const cached = await createKvCacheStore(env.SLACK_KV).get("repos:cache", "json"); - if (cached && Array.isArray(cached)) { + const parsed = z.array(repoConfigSchema).safeParse(cached); + if (parsed.success) { log.info("control_plane.fetch_repos", { source: "kv_cache" }); - return cached as RepoConfig[]; + return parsed.data; } } catch (e) { log.warn("kv.get", { diff --git a/packages/slack-bot/src/index.test.ts b/packages/slack-bot/src/index.test.ts index 9ee607035..667539725 100644 --- a/packages/slack-bot/src/index.test.ts +++ b/packages/slack-bot/src/index.test.ts @@ -56,6 +56,24 @@ function createMockKV() { }; } +function mockReposResponseBody(repos: Array>) { + return { + repos: repos.map((repo, index) => ({ + ...repo, + id: typeof repo.id === "number" ? repo.id : index + 1, + fullName: + typeof repo.fullName === "string" + ? repo.fullName + : `${String(repo.owner)}/${String(repo.name)}`, + description: + repo.description === null || typeof repo.description === "string" ? repo.description : null, + archived: typeof repo.archived === "boolean" ? repo.archived : false, + })), + cached: false, + cachedAt: "2026-07-27T00:00:00.000Z", + }; +} + function makeEnv(): Env { return { SLACK_KV: createMockKV() as unknown as KVNamespace, @@ -67,8 +85,8 @@ function makeEnv(): Env { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { return new Response( - JSON.stringify({ - repos: [ + JSON.stringify( + mockReposResponseBody([ { id: "acme/app", owner: "acme", @@ -77,8 +95,8 @@ function makeEnv(): Env { defaultBranch: "main", private: true, }, - ], - }), + ]) + ), { status: 200, headers: { "Content-Type": "application/json" }, @@ -129,12 +147,12 @@ function buildNumberedRepos(count: number) { } /** Point CONTROL_PLANE.fetch at a fixed repo list (other routes return enabledModels). */ -function mockReposFetch(env: Env, repos: unknown[]) { +function mockReposFetch(env: Env, repos: Array>) { (env.CONTROL_PLANE.fetch as unknown as ReturnType).mockImplementation( async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { - return new Response(JSON.stringify({ repos }), { + return new Response(JSON.stringify(mockReposResponseBody(repos)), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -176,8 +194,8 @@ function makeSessionEnv( if (url.includes("/repos")) { order.push("repos"); return new Response( - JSON.stringify({ - repos: [ + JSON.stringify( + mockReposResponseBody([ { id: "acme/app", owner: "acme", @@ -186,8 +204,8 @@ function makeSessionEnv( defaultBranch: "main", private: true, }, - ], - }), + ]) + ), { status: 200, headers: { "Content-Type": "application/json" }, @@ -553,13 +571,13 @@ describe("POST /events", () => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { return new Response( - JSON.stringify({ - repos: [ + JSON.stringify( + mockReposResponseBody([ { owner: "acme", name: "web", defaultBranch: "main", private: true }, { owner: "acme", name: "api", defaultBranch: "main", private: true }, { owner: "acme", name: "docs", defaultBranch: "main", private: true }, - ], - }), + ]) + ), { status: 200, headers: { "Content-Type": "application/json" } } ); } @@ -1417,7 +1435,7 @@ describe("POST /interactions", () => { async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { - return new Response(JSON.stringify({ repos: [] }), { + return new Response(JSON.stringify(mockReposResponseBody([])), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -1822,8 +1840,8 @@ describe("POST /interactions", () => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { return new Response( - JSON.stringify({ - repos: [ + JSON.stringify( + mockReposResponseBody([ { id: "acme/app", owner: "acme", @@ -1832,8 +1850,8 @@ describe("POST /interactions", () => { defaultBranch: "main", private: true, }, - ], - }), + ]) + ), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1945,8 +1963,8 @@ describe("POST /interactions", () => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { return new Response( - JSON.stringify({ - repos: [ + JSON.stringify( + mockReposResponseBody([ { id: "acme/app", owner: "acme", @@ -1955,8 +1973,8 @@ describe("POST /interactions", () => { defaultBranch: "main", private: true, }, - ], - }), + ]) + ), { status: 200, headers: { "Content-Type": "application/json" } } ); } @@ -2050,8 +2068,8 @@ describe("POST /interactions", () => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/repos")) { return new Response( - JSON.stringify({ - repos: [ + JSON.stringify( + mockReposResponseBody([ { id: "acme/app", owner: "acme", @@ -2060,8 +2078,8 @@ describe("POST /interactions", () => { defaultBranch: "main", private: true, }, - ], - }), + ]) + ), { status: 200, headers: { "Content-Type": "application/json" } } ); } diff --git a/packages/web/src/app/api/image-builds/route.ts b/packages/web/src/app/api/image-builds/route.ts index 65f6cb618..4ab67974e 100644 --- a/packages/web/src/app/api/image-builds/route.ts +++ b/packages/web/src/app/api/image-builds/route.ts @@ -41,9 +41,11 @@ export async function GET() { return NextResponse.json({ error: "Failed to fetch image builds" }, { status: 502 }); } - const enabledData = await enabledResponse.json(); - const enabledReposData = await enabledReposResponse.json(); - const statusData = await statusResponse.json(); + const [enabledData, enabledReposData, statusData] = await Promise.all([ + enabledResponse.json(), + enabledReposResponse.json(), + statusResponse.json(), + ]); // The enabled feed also carries the cron's repository lists — serve the // scope identity plus the current fingerprint the status fold keys on. diff --git a/packages/web/src/components/media-lightbox.tsx b/packages/web/src/components/media-lightbox.tsx index 0c5c312a7..a9b3095dd 100644 --- a/packages/web/src/components/media-lightbox.tsx +++ b/packages/web/src/components/media-lightbox.tsx @@ -3,7 +3,14 @@ import { useEffect, useState } from "react"; import type { Artifact } from "@/types/session"; import { buildSessionMediaUrl } from "@/lib/media"; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { XIcon } from "@/components/ui/icons"; interface MediaLightboxProps { sessionId: string; @@ -26,14 +33,20 @@ export function MediaLightbox({ sessionId, artifact, open, onOpenChange }: Media return ( - - {caption} - + + + + + {caption} + {artifact?.metadata?.sourceUrl || (isVideo ? "Session video recording" : "Session screenshot")} -
+
{!artifact ? (
No media selected @@ -44,7 +57,7 @@ export function MediaLightbox({ sessionId, artifact, open, onOpenChange }: Media
-