From 9ccd3bd58429517eb56e3e57e5307f51af3ddc3c Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 11:36:55 -0700 Subject: [PATCH 01/68] fix: require active-author membership for team child spawns (#2213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Supersedes #2191, taking over its original fix at `eec0f170` and resolving conflicts against current `main` (`4664486`). Credits the original author as a co-author. PR #2191 authorized team-owned child creation using `promptAuthor.canonicalUserId ?? parentSession.userId`. An unresolved author could therefore borrow the parent owner's current team membership and create a team-owned child using the parent sandbox token. - Require the active prompt author's own canonical identity and current membership in the parent's owning team before resolving the child repository, acquiring an admission lease, or creating the child. - Return `403 { error: "Not a team member", code: "not_member" }` for unresolved or nonmember authors in every `TEAMS_ENFORCEMENT` mode. - Preserve existing parent-owner fallback for workspace-owned child ownership, including private sessions; it is not used for team authorization. - Preserve newer repository permission/grant checks, fresh SCM repository-ID resolution, and inherited environment ownership checks when resolving the original merge conflicts. - Update successful team/environment test fixtures to use canonical member authors while retaining coverage of inheritance without human environment-use permission. ## Regression Coverage - Real workerd/D1 integration tests reject removed and unresolved prompt authors in `off`, `shadow`, and `on`, even when the parent owner remains a team member and the repository grant is valid. - Rejections leave no child or admission lease and do not reach SCM repository resolution. - Unit tests distinguish the request caller, parent owner, and active author, cover absent/null canonical identities and membership in another team, and permit a member author even when the parent owner is no longer a member. - Existing private/workspace ownership and credential-identity regressions remain passing. ## Verification Before correcting the original PR's fallback-based gate, the unresolved-author integration regression failed in all three modes with **expected 403, received 201**. With this fix, all targeted integration suites pass. - `npm run build -w @open-inspect/shared` - `npm test -w @open-inspect/control-plane -- --maxWorkers=1 --silent --reporter=dot`: **6,176 tests passed across 362 files**. - `npm run test:integration -w @open-inspect/control-plane -- test/integration/spawn-children.test.ts test/integration/session-environment-ownership.test.ts test/integration/child-session-ops.test.ts test/integration/session-access-routes.test.ts test/integration/rbac-routes.test.ts --maxWorkers=1 --silent`: **126 tests passed across 5 files**. - `npm run typecheck -w @open-inspect/control-plane`: passed, including integration test types. - `npm run build -w @open-inspect/control-plane`: Worker and Node builds passed. - ESLint and Prettier checks on all four changed files, `git diff --check`, and commit hooks passed. The full integration suite was not run locally. Membership lookup and child persistence remain separate operations, as in existing session creation; this change addresses missing or revoked membership before the request, not concurrent revocation during an authorized request. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/cff7c2e448e88bb119050ab1c6b3c491)* ## Summary by CodeRabbit * **Bug Fixes** * Team-owned child sessions are now created only when the active prompt author is a member of the parent’s team. Requests from authors who are not members or cannot be identified are rejected with a 403 response before a child session is created. * When spawning is allowed, the child session reflects the active author’s team membership and canonical user identity. * For non-team child sessions, the parent’s user identity remains the fallback when the prompt author has no canonical identity. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: Rahul Sethuram Co-authored-by: waclaude --- .../src/router.spawn-child.test.ts | 68 ++++++++++++++++++ .../src/routes/session-child-spawn.ts | 18 ++++- .../control-plane/test/integration/helpers.ts | 3 +- .../session-environment-ownership.test.ts | 5 +- .../test/integration/spawn-children.test.ts | 71 +++++++++++++++++++ 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index 2cb5dce25f..0c63b6e7e7 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -385,6 +385,74 @@ describe("handleSpawnChild prompt enqueue handling", () => { return initRequest.json<{ reasoningEffort: string | null }>(); } + it.each([undefined, null, "nonmember-author"])( + "refuses a team child when the active author is unresolved or a nonmember (%s)", + async (canonicalUserId) => { + const context = { + ...spawnContext, + promptAuthor: { ...spawnContext.promptAuthor, userId: "slack:U2", canonicalUserId }, + }; + const store = makeStore("canonical-user-123", context, null, "team_alpha"); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return store as never; + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockImplementation(async (userId) => + userId === "canonical-user-123" + ? new Map([["team_alpha", "member"]]) + : new Map([["team_other", "member"]]) + ); + const { env, childStub } = makeSuccessfulEnv(context, [ + ...actorTargetPermissions, + "repositories.use", + ]); + + const response = await makeRequest(env); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "not_member" }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + if (canonicalUserId) { + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledWith(canonicalUserId); + } + } + ); + + it("uses the active author's membership and ownership rather than the parent owner's", async () => { + const context = { + ...spawnContext, + promptAuthor: { + ...spawnContext.promptAuthor, + userId: "slack:U2", + canonicalUserId: "canonical-author-2", + }, + }; + const store = makeStore("former-owner", context, null, "team_alpha"); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return store as never; + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockImplementation(async (userId) => + userId === "former-owner" ? new Map() : new Map([["team_alpha", "member"]]) + ); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamRepositoryGrantStore.prototype, "covers").mockResolvedValue(true); + const { env } = makeSuccessfulEnv(context, [...actorTargetPermissions, "repositories.use"]); + + expect((await makeRequest(env)).status).toBe(201); + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledWith("canonical-author-2"); + expect(store.create).toHaveBeenCalledWith( + expect.objectContaining({ ownerTeamId: "team_alpha", userId: "canonical-author-2" }) + ); + }); + it("inherits the parent's reasoning effort when omitted", async () => { const store = makeStore(); vi.mocked(SessionIndexStore).mockImplementation(function () { diff --git a/packages/control-plane/src/routes/session-child-spawn.ts b/packages/control-plane/src/routes/session-child-spawn.ts index 43b10f25b9..25a766ef25 100644 --- a/packages/control-plane/src/routes/session-child-spawn.ts +++ b/packages/control-plane/src/routes/session-child-spawn.ts @@ -21,6 +21,7 @@ import { import { generateId } from "../auth/crypto"; import { getEffectiveEnabledModels } from "../db/model-preferences"; import { SessionIndexStore } from "../db/session-index"; +import { TeamMembershipStore } from "../db/team-memberships"; import { createLogger } from "../logger"; import { SessionInternalPaths } from "../session/contracts"; import type { EnqueuePromptRequest } from "../session/enqueue-prompt-contract"; @@ -182,6 +183,19 @@ export async function handleSpawnChild( if (targetAuthorizationError) return targetAuthorizationError; const teamId = parentSession?.ownerTeamId ?? null; + // Sandbox callers skip route authorization. Require the active author's own + // team membership rather than borrowing the parent's ownership fallback. + const childOwnerUserId = + spawnContext.promptAuthor.canonicalUserId ?? + (teamId === null ? (parentSession?.userId ?? null) : null); + if ( + teamId && + (!childOwnerUserId || + !(await new TeamMembershipStore(ctx.db).listForUser(childOwnerUserId)).has(teamId)) + ) { + return json({ error: "Not a team member", code: "not_member" }, 403); + } + let childRepoId = spawnContext.repoId; if (teamId && parentRepoOwner && parentRepoName) { const resolved = await resolveRepoOrError(env, parentRepoOwner, parentRepoName, ctx, logger); @@ -287,7 +301,7 @@ export async function handleSpawnChild( ); const input: SessionInitInput = { - ownerTeamId: parentSession?.ownerTeamId ?? null, + ownerTeamId: teamId, visibility: parentSession?.visibility ?? "workspace", sessionId: childId, repoOwner: spawnContext.repoOwner, @@ -303,7 +317,7 @@ export async function handleSpawnChild( model, reasoningEffort, participantUserId: spawnContext.promptAuthor.userId, - platformUserId: spawnContext.promptAuthor.canonicalUserId ?? parentSession?.userId ?? null, + platformUserId: childOwnerUserId, participantCanonicalUserId: spawnContext.promptAuthor.canonicalUserId ?? null, collaboratorSourceSessionId: parentId, scmLogin: spawnContext.promptAuthor.scmLogin, diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 0ce549e050..3efdf118c8 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -242,6 +242,7 @@ export async function initSession(overrides?: { reasoningEffort?: string; sandboxSettings?: SandboxSettings; userId?: string; + canonicalUserId?: string; scmLogin?: string; providerAuth?: SessionModelProviderAuthInput[]; }) { @@ -277,7 +278,7 @@ export async function initSession(overrides?: { ], environmentId: defaults.environmentId ?? null, status: "created", - userId: defaults.userId, + userId: defaults.canonicalUserId ?? defaults.userId, providerAuth, createdAt: now, updatedAt: now, diff --git a/packages/control-plane/test/integration/session-environment-ownership.test.ts b/packages/control-plane/test/integration/session-environment-ownership.test.ts index ed24e8b360..7f6260a54b 100644 --- a/packages/control-plane/test/integration/session-environment-ownership.test.ts +++ b/packages/control-plane/test/integration/session-environment-ownership.test.ts @@ -62,6 +62,7 @@ async function sandboxParent( defaultBranch: BASE_BRANCH, environmentId, userId: MEMBER, + canonicalUserId: MEMBER, scmLogin: "environment-member", }); await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = ? WHERE id = ?") @@ -240,7 +241,9 @@ describe("session environment ownership compatibility", () => { "inherits %s into %s/%s without human use access", async (environmentId, ownerTeamId, visibility) => { const parent = await sandboxParent(environmentId, ownerTeamId, visibility); - await env.DB.prepare("DELETE FROM team_memberships WHERE user_id = ?").bind(MEMBER).run(); + await env.DB.prepare("DELETE FROM team_memberships WHERE user_id = ? AND team_id != ?") + .bind(MEMBER, ownerTeamId ?? "") + .run(); await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") .bind(BUILT_IN_ROLE_REGISTRY.viewer.id, MEMBER) .run(); diff --git a/packages/control-plane/test/integration/spawn-children.test.ts b/packages/control-plane/test/integration/spawn-children.test.ts index 6372003b23..2e3fe23cc1 100644 --- a/packages/control-plane/test/integration/spawn-children.test.ts +++ b/packages/control-plane/test/integration/spawn-children.test.ts @@ -3,6 +3,7 @@ import { SELF, env, createExecutionContext } from "cloudflare:test"; import { runInSessionDO } from "./session-do-access"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; import { cleanD1Tables } from "./cleanup"; @@ -136,6 +137,8 @@ describe("POST /sessions/:parentId/children — spawn child", () => { repoName: "web-app", defaultBranch: "main", }); + await seedActiveUser("canonical-abc123"); + await new TeamMembershipStore(env.DB).add("team_child", "canonical-abc123"); const { parentName, sandboxToken, store } = await setupParent({ ownerTeamId: "team_child", visibility: "workspace", @@ -189,6 +192,74 @@ describe("POST /sessions/:parentId/children — spawn child", () => { expect(state.status).toBe("active"); }); + it.each([ + ["off", "removed"], + ["off", "unresolved"], + ["shadow", "removed"], + ["shadow", "unresolved"], + ["on", "removed"], + ["on", "unresolved"], + ] as const)( + "refuses a team-owned child (%s) when the prompt author is %s", + async (mode, authorState) => { + const ownerId = "11111111111111111111111111111111"; + const authorId = authorState === "removed" ? "33333333333333333333333333333333" : undefined; + await seedActiveUser(ownerId); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_spawn', 'spawn', 'Spawn', 1, 1)" + ).run(); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add("team_spawn", ownerId); + if (authorId) { + await seedActiveUser(authorId); + await memberships.add("team_spawn", authorId); + } + await new TeamRepositoryGrantStore(env.DB).add("team_spawn", { + kind: "repository", + repoExternalId: 12345, + owner: "acme", + name: "web-app", + }); + const repositoryAccess = vi + .spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess") + .mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + const { parentName, sandboxToken, store } = await setupParent({ + ownerTeamId: "team_spawn", + visibility: "team", + repoId: 12345, + userId: "slack:U0123", + canonicalUserId: authorId, + }); + await env.DB.prepare("UPDATE sessions SET user_id = ? WHERE id = ?") + .bind(ownerId, parentName) + .run(); + if (authorId) await memberships.remove("team_spawn", authorId); + + const response = await routeRequest( + new Request(`https://test.local/sessions/${parentName}/children`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ title: "Team child", prompt: "Investigate" }), + }), + { ...env, TEAMS_ENFORCEMENT: mode }, + createExecutionContext() + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "not_member" }); + expect(await store.countTotalChildren(parentName)).toBe(0); + expect( + await env.DB.prepare("SELECT COUNT(*) AS count FROM child_admission_leases").first() + ).toEqual({ count: 0 }); + expect(repositoryAccess).not.toHaveBeenCalled(); + } + ); + it("inherits private visibility, owner and collaborators when the prompt author is not canonical", async () => { const ownerId = "11111111111111111111111111111111"; const collaboratorId = "22222222222222222222222222222222"; From d0a470e215903e6943377441015da112bcabf964 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 11:39:37 -0700 Subject: [PATCH 02/68] fix(web): keep team pages open after slug renames (#2215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Supersedes #2190 by taking over its slug-rename fix on the current `main` branch. The original PR branch is unchanged, and @rhlsthrm's commit authorship is retained. - Keep the displayed team resolved by its ID while the team list revalidates after a slug rename, then replace the URL with the team's current encoded slug. - Resolve the `TeamPage` conflicts while preserving the shared workspace-admin check, automation permission checks, and repository/environment/automation tabs added on `main`. - Update the regression test's authorization mock and complete capability fixture for the current APIs. ## Verification - `npm run build -w @open-inspect/shared` - `npm test -w @open-inspect/web -- src/components/teams/ src/components/settings/teams-settings.test.tsx src/components/settings/teams-settings-cache.test.tsx --maxWorkers=1`: 7 files, 109 tests passed. - `npm run typecheck -w @open-inspect/web` - `npm run lint -w @open-inspect/web` - Prettier check on all three changed files and `git diff --check` passed. - Browser verification against the local Next.js app with mocked API responses: renaming from Settings updates the route and keeps the team available at desktop (1440x1000) and mobile (390x844) sizes. Mobile Settings can be reopened with the saved slug, with no horizontal overflow. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/c671ab008b98ba858c7f1896c0bee706)* ## Summary by CodeRabbit * **Bug Fixes** * After a team is renamed, its page remains visible and the URL updates to the team’s new slug, even if the directory is stale, refresh fails, or the old slug is reused by another team. * Archived teams are not treated as active matches when resolving a team page. Navigating to a missing or archived team slug continues to show “Team not found” without redirecting. * Team lists now reflect successful team updates without waiting for a refresh. --------- Co-authored-by: Rahul Sethuram Co-authored-by: waclaude --- .../src/components/teams/team-page.test.tsx | 245 ++++++++++++++++++ .../web/src/components/teams/team-page.tsx | 46 +++- .../src/components/teams/teams-pages.test.tsx | 40 +++ packages/web/src/hooks/use-teams.test.tsx | 83 ++++++ packages/web/src/hooks/use-teams.ts | 17 +- 5 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/components/teams/team-page.test.tsx diff --git a/packages/web/src/components/teams/team-page.test.tsx b/packages/web/src/components/teams/team-page.test.tsx new file mode 100644 index 0000000000..881322503a --- /dev/null +++ b/packages/web/src/components/teams/team-page.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +/// + +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SWRConfig, useSWRConfig } from "swr"; +import type { TeamResponse } from "@/hooks/use-teams"; +import { TeamPage } from "./team-page"; + +expect.extend(matchers); + +const router = vi.hoisted(() => ({ replace: vi.fn() })); +const { replace } = router; + +vi.mock("next/navigation", () => ({ useRouter: () => router })); +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: { id: "user_one" } }, status: "authenticated" }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { role: { key: "owner" }, suspendedAt: null }, + hasPermission: (permission: string) => permission === "automations.read", + }), +})); +vi.mock("./team-overview", () => ({ TeamOverview: () =>

Team session buckets

})); +vi.mock("@/components/settings/team-members-table", () => ({ + TeamMembersTable: () =>

Team member table

, +})); + +let stored: TeamResponse; +let reusedSlugTeam: TeamResponse | undefined; +let directoryRefresh: "fresh" | "stale" | "failed"; +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + reusedSlugTeam = undefined; + directoryRefresh = "fresh"; + stored = { + id: "team_design", + slug: "design", + name: "Design", + description: null, + joinPolicy: "open", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + memberCount: 0, + capabilities: { + canJoin: false, + canLeave: false, + canEditMetadata: true, + canManageMembers: false, + canManageRepositories: false, + canManageBindings: false, + canManageAutomations: false, + canManageEnvironments: false, + canManageSecrets: false, + canArchive: true, + }, + }; + const initialTeam = stored; + const otherTeam = { ...stored, id: "team_other", slug: "engineering", name: "Engineering" }; + fetchMock.mockImplementation(async (input, init) => { + const path = String(input); + if (path === "/api/teams") { + if (stored !== initialTeam && directoryRefresh === "failed") + return Response.json({ error: "Unavailable" }, { status: 503 }); + return Response.json({ + teams: [ + ...(reusedSlugTeam && stored.slug !== reusedSlugTeam.slug + ? [stored, reusedSlugTeam] + : [directoryRefresh === "stale" ? initialTeam : stored]), + otherTeam, + ], + }); + } + if (path === "/api/me/teams") return Response.json({ teams: [] }); + if (path === "/api/teams/team_design/members") return Response.json({ members: [] }); + if (path === "/api/teams/team_design" && init?.method === "PATCH") { + stored = { ...stored, ...JSON.parse(String(init.body)), updatedAt: 2 }; + return Response.json(stored); + } + if (path === "/api/teams/team_design") return Response.json(stored); + if (path === "/api/teams/team_reused") return Response.json(reusedSlugTeam); + return Response.json({ error: "not found" }, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderPage(slug: string) { + const cache = new Map(); + const view = render(, { + wrapper: ({ children }) => ( + cache, + dedupingInterval: 0, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + }} + > + + {children} + + ), + }); + return { ...view, cache }; +} + +function RefreshDirectory() { + const { mutate } = useSWRConfig(); + return ; +} + +describe("TeamPage", () => { + it.each([ + ["fresh", false], + ["fresh", true], + ["stale", false], + ["failed", false], + ] as const)( + "keeps the PATCH result after a slug rename (directory: %s, old slug reused: %s)", + async (refresh, reuseOldSlug) => { + directoryRefresh = refresh; + if (reuseOldSlug) reusedSlugTeam = { ...stored, id: "team_reused", name: "New Design Team" }; + const { cache } = renderPage("design"); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + fireEvent.change(screen.getByRole("textbox", { name: "Slug" }), { + target: { value: "product-design" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled() + ); + + expect(screen.getByRole("heading", { level: 1, name: "Design" })).toBeInTheDocument(); + expect(screen.queryByText("Team not found.")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Slug" })).toHaveValue("product-design"); + expect(replace).toHaveBeenCalledWith("/teams/product-design"); + expect(cache.get("/api/teams")?.data?.teams).toContainEqual(stored); + expect(cache.get("/api/teams")?.data?.teams).toContainEqual( + expect.objectContaining({ id: "team_other", slug: "engineering" }) + ); + expect(cache.get("/api/teams/team_design")?.data).toEqual(stored); + expect(fetchMock.mock.calls.filter(([path]) => path === "/api/teams")).toHaveLength(1); + + if (reuseOldSlug) { + fireEvent.click(screen.getByRole("button", { name: "Refresh directory" })); + await waitFor(() => + expect(cache.get("/api/teams")?.data?.teams).toContainEqual(reusedSlugTeam) + ); + expect(screen.getByRole("heading", { level: 1, name: "Design" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Slug" })).toHaveValue("product-design"); + expect(replace).toHaveBeenCalledTimes(1); + } + } + ); + + it.each(["rerender", "remount"] as const)( + "resolves the canonical route after %s with a stale directory", + async (navigation) => { + directoryRefresh = "stale"; + stored = { ...stored, slug: "product-design", updatedAt: 2 }; + const { cache, rerender } = renderPage("design"); + + await waitFor(() => expect(replace).toHaveBeenCalledWith("/teams/product-design")); + + rerender( + + ); + + expect(cache.get("/api/teams")?.data?.teams).toContainEqual( + expect.objectContaining({ id: "team_design", slug: "product-design" }) + ); + expect(screen.getByRole("heading", { level: 1, name: "Design" })).toBeInTheDocument(); + expect(screen.queryByText("Team not found.")).not.toBeInTheDocument(); + } + ); + + it.each(["stale", "failed", "forbidden"] as const)( + "reconciles the PATCH while an older directory refresh is %s", + async (refresh) => { + const initialTeam = stored; + const { cache } = renderPage("design"); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + let finishRefresh!: (response: Response) => void; + const pendingRefresh = new Promise((resolve) => { + finishRefresh = resolve; + }); + const fetchNormally = fetchMock.getMockImplementation()!; + fetchMock.mockImplementation((input, init) => + String(input) === "/api/teams" ? pendingRefresh : fetchNormally(input, init) + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh directory" })); + await waitFor(() => + expect(fetchMock.mock.calls.filter(([path]) => path === "/api/teams")).toHaveLength(2) + ); + + fireEvent.change(screen.getByRole("textbox", { name: "Slug" }), { + target: { value: "product-design" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled() + ); + expect(replace).toHaveBeenCalledWith("/teams/product-design"); + + await act(async () => { + finishRefresh( + refresh === "stale" + ? Response.json({ teams: [initialTeam] }) + : Response.json({ error: "Unavailable" }, { status: refresh === "failed" ? 503 : 403 }) + ); + await pendingRefresh; + }); + + expect(cache.get("/api/teams")?.data?.teams).toContainEqual(stored); + expect(cache.get("/api/teams/team_design")?.data).toEqual(stored); + if (refresh === "forbidden") { + expect(screen.getByRole("alert")).toHaveTextContent("Unable to load team."); + expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument(); + } else { + expect(screen.getByRole("heading", { level: 1, name: "Design" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Slug" })).toHaveValue("product-design"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + } + } + ); +}); diff --git a/packages/web/src/components/teams/team-page.tsx b/packages/web/src/components/teams/team-page.tsx index b8a4a7de97..3ab6b8926f 100644 --- a/packages/web/src/components/teams/team-page.tsx +++ b/packages/web/src/components/teams/team-page.tsx @@ -1,9 +1,14 @@ "use client"; import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSWRConfig } from "swr"; import { + TEAMS_KEY, + isRetryableTeamError, + reconcileTeamDirectory, useMeTeams, useTeam, useTeamMembers, @@ -35,7 +40,19 @@ export function TeamPage({ slug }: { slug: string }) { const { teams, loading, error } = useTeams(); const mine = useMeTeams(); const { authorization, hasPermission } = useCurrentUserAuthorization(); - const team = teams.find((candidate) => candidate.slug === slug && candidate.archivedAt === null); + const [shownTeam, setShownTeam] = useState<{ id: string | null; routeSlug: string }>({ + id: null, + routeSlug: slug, + }); + // Retain identity on the current route, even when another team reuses its slug. + const team = + (shownTeam.routeSlug === slug + ? teams.find((candidate) => candidate.id === shownTeam.id && candidate.archivedAt === null) + : undefined) ?? + teams.find((candidate) => candidate.slug === slug && candidate.archivedAt === null); + if (shownTeam.routeSlug !== slug || (team && team.id !== shownTeam.id)) { + setShownTeam({ id: team?.id ?? null, routeSlug: slug }); + } const role = authorization?.role.key; const admin = authorization?.suspendedAt === null && isWorkspaceAdmin(role); const member = @@ -50,12 +67,14 @@ export function TeamPage({ slug }: { slug: string }) { Loading team...

); - if (error) return Unable to load team.; + if (error && (!team || !isRetryableTeamError(error))) + return Unable to load team.; if (!team) return

Team not found.

; return ( @@ -64,14 +83,35 @@ export function TeamPage({ slug }: { slug: string }) { function TeamContent({ initialTeam, + slug, canViewWork, canReadAutomations, }: { initialTeam: TeamResponse; + slug: string; canViewWork: boolean; canReadAutomations: boolean; }) { + const router = useRouter(); + const { mutate } = useSWRConfig(); const { team: currentTeam, error } = useTeam(initialTeam.id); + // The ID-keyed detail cache holds the PATCH response even when directory reads lag. + const canonicalSlug = !error && currentTeam?.archivedAt === null ? currentTeam.slug : undefined; + useEffect(() => { + if (!currentTeam || !canonicalSlug || canonicalSlug === slug) return; + let cancelled = false; + void mutate( + TEAMS_KEY, + (current: { teams: TeamResponse[] } | undefined) => + reconcileTeamDirectory(current, currentTeam), + { revalidate: false } + ).then(() => { + if (!cancelled) router.replace(`/teams/${encodeURIComponent(canonicalSlug)}`); + }); + return () => { + cancelled = true; + }; + }, [router, mutate, slug, canonicalSlug, currentTeam]); const team = currentTeam ?? initialTeam; const capabilities = useTeamCapabilities(team); const [tab, setTab] = useState("Overview"); diff --git a/packages/web/src/components/teams/teams-pages.test.tsx b/packages/web/src/components/teams/teams-pages.test.tsx index 23e83d4a3b..7a5e662daf 100644 --- a/packages/web/src/components/teams/teams-pages.test.tsx +++ b/packages/web/src/components/teams/teams-pages.test.tsx @@ -22,8 +22,10 @@ const mocks = vi.hoisted(() => ({ join: vi.fn(), repositories: vi.fn(), secrets: vi.fn(), + replace: vi.fn(), })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) })); vi.mock("@/lib/auth-session", () => ({ useAuthSession: () => ({ data: { user: { id: "user_one" } }, status: "authenticated" }), })); @@ -190,6 +192,44 @@ describe("Teams index", () => { }); describe("Team page tabs", () => { + it("follows navigation to a different active team", () => { + mocks.teams = [ + team, + { ...team, id: "team_engineering", slug: "engineering", name: "Engineering" }, + ]; + const view = render(); + expect(screen.getByRole("heading", { name: "Design" })).toBeInTheDocument(); + + view.rerender(); + + expect(screen.getByRole("heading", { name: "Engineering" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Design" })).not.toBeInTheDocument(); + expect(mocks.replace).not.toHaveBeenCalled(); + }); + + it.each(["missing", "archived"])( + "does not retain a team when navigating to the %s slug", + (slug) => { + mocks.teams = [team, { ...team, id: "team_archived", slug: "archived", archivedAt: 2 }]; + const view = render(); + expect(screen.getByRole("heading", { name: "Design" })).toBeInTheDocument(); + + view.rerender(); + + expect(screen.getByText("Team not found.")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Design" })).not.toBeInTheDocument(); + expect(mocks.replace).not.toHaveBeenCalled(); + + mocks.teams = [ + { ...team, slug: "product-design" }, + { ...team, id: "team_reused", name: "New Design Team" }, + ]; + view.rerender(); + expect(screen.getByRole("heading", { name: "New Design Team" })).toBeInTheDocument(); + expect(mocks.replace).not.toHaveBeenCalled(); + } + ); + it("shows the header and Members to a nonmember, even if mutation capabilities are present", () => { mocks.teams = [ { diff --git a/packages/web/src/hooks/use-teams.test.tsx b/packages/web/src/hooks/use-teams.test.tsx index fb0d0d9dc7..7dba7ffea2 100644 --- a/packages/web/src/hooks/use-teams.test.tsx +++ b/packages/web/src/hooks/use-teams.test.tsx @@ -383,6 +383,89 @@ describe("team hooks", () => { expect(result.current.detail.team?.capabilities?.canJoin).toBe(false); }); + it("does not seed a partial directory when updating from a detail-only route", async () => { + vi.mocked(useAuthSession).mockReturnValue({ + data: { user: { id: "user_one", name: "Ada" } }, + status: "authenticated", + }); + const updated = { ...membership, slug: "product-design", updatedAt: 2 }; + const otherTeam = { ...membership, id: "team_other", slug: "engineering" }; + vi.mocked(browserApiFetch).mockImplementation(async (path, init) => { + if (init?.method === "PATCH") return Response.json(updated); + if (path === "/api/teams") return Response.json({ teams: [updated, otherTeam] }); + return Response.json(membership); + }); + const { result, rerender } = renderHook( + ({ directoryEnabled }) => ({ + detail: useTeam(membership.id), + directory: useTeams(directoryEnabled), + cache: useSWRConfig().cache, + }), + { initialProps: { directoryEnabled: false }, wrapper } + ); + await waitFor(() => expect(result.current.detail.team?.id).toBe(membership.id)); + + await act(() => result.current.detail.updateTeam({ slug: "product-design" })); + + expect(result.current.cache.get("/api/teams")?.data).toBeUndefined(); + expect(result.current.detail.team?.slug).toBe("product-design"); + expect( + vi.mocked(browserApiFetch).mock.calls.filter(([path]) => path === "/api/teams") + ).toHaveLength(0); + + rerender({ directoryEnabled: true }); + await waitFor(() => expect(result.current.directory.teams).toHaveLength(2)); + expect(result.current.directory.teams.map(({ id }) => id)).toEqual([ + membership.id, + otherTeam.id, + ]); + }); + + it("revalidates a mounted directory that has no data when a PATCH commits", async () => { + vi.mocked(useAuthSession).mockReturnValue({ + data: { user: { id: "user_one", name: "Ada" } }, + status: "authenticated", + }); + const updated = { ...membership, slug: "product-design", updatedAt: 2 }; + const otherTeam = { ...membership, id: "team_other", slug: "engineering" }; + let finishInitialDirectory!: (response: Response) => void; + const initialDirectory = new Promise((resolve) => { + finishInitialDirectory = resolve; + }); + let directoryRequests = 0; + vi.mocked(browserApiFetch).mockImplementation(async (path, init) => { + if (init?.method === "PATCH") return Response.json(updated); + if (path === "/api/teams") { + directoryRequests += 1; + return directoryRequests === 1 + ? initialDirectory + : Response.json({ teams: [updated, otherTeam] }); + } + return Response.json(membership); + }); + const { result } = renderHook( + () => ({ detail: useTeam(membership.id), directory: useTeams() }), + { wrapper } + ); + await waitFor(() => expect(result.current.detail.team?.id).toBe(membership.id)); + expect(result.current.directory.teams).toEqual([]); + + await act(() => result.current.detail.updateTeam({ slug: "product-design" })); + + expect(directoryRequests).toBe(2); + expect(result.current.directory.teams.map(({ id }) => id)).toEqual([ + membership.id, + otherTeam.id, + ]); + expect(result.current.directory.teams[0]?.slug).toBe("product-design"); + await act(async () => { + finishInitialDirectory(Response.json({ teams: [membership] })); + await initialDirectory; + }); + expect(result.current.directory.teams).toHaveLength(2); + expect(result.current.directory.teams[0]?.slug).toBe("product-design"); + }); + it.each(["create", "update", "archive", "restore", "set-member", "remove-member"] as const)( "refreshes the user-scoped membership cache after %s", async (operation) => { diff --git a/packages/web/src/hooks/use-teams.ts b/packages/web/src/hooks/use-teams.ts index bc21bb6f33..395227c510 100644 --- a/packages/web/src/hooks/use-teams.ts +++ b/packages/web/src/hooks/use-teams.ts @@ -18,7 +18,7 @@ import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; import { useAuthSession } from "@/lib/auth-session"; import { ME_TEAMS_API_PATH, isMeTeamsCacheKey, meTeamsKey } from "@/lib/me-teams-cache"; -const TEAMS_KEY = "/api/teams"; +export const TEAMS_KEY = "/api/teams"; // Missing or incomplete capabilities leave the team visible while every team action stays disabled. const teamSchema = teamResponseSchema.extend({ capabilities: teamResponseSchema.shape.capabilities.partial().optional(), @@ -31,6 +31,15 @@ const meTeamsSchema = meTeamsResponseSchema.extend({ }); const membersSchema = z.object({ members: z.array(teamMemberSchema) }); +export function reconcileTeamDirectory( + current: z.infer | undefined, + team: TeamResponse +) { + return current + ? { teams: [...current.teams.filter((existing) => existing.id !== team.id), team] } + : current; +} + class TeamRequestError extends Error { constructor( message: string, @@ -158,7 +167,11 @@ export function useTeam(id: string) { const team = await write(key, "PATCH", input, teamSchema); await Promise.allSettled([ mutate(key, team, { revalidate: false }), - mutate(TEAMS_KEY), + mutate( + TEAMS_KEY, + (current: z.infer | undefined) => reconcileTeamDirectory(current, team), + { revalidate: (data) => data === undefined } + ), mutate(isMeTeamsCacheKey), ]); return team; From 452b0b96033924849deb213ae969f23727129ebd Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 12:05:49 -0700 Subject: [PATCH 03/68] fix: start archive preservation before awaiting status projection (#2202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes COL-227. - Commit the local archive status through `beginTransition()` before invoking preservation. A local persistence failure throws synchronously and skips the save; the existing async `transition()` API retains its promise-rejection behavior. - Start preservation before awaiting the D1 status projection. The synchronous `archived` and `draining` writes remain in the same Durable Object turn, so a bridge reconnect receives HTTP 503 while saving still needs its sandbox. - Keep archive eligibility checks and final index confirmation unchanged. Missing/legacy shutdown records still receive HTTP 410. No migrations or persisted fields are added. - Cover the delayed projection and failed local write with integration regressions. Restore the projection spy before awaiting rejection-safe archive settlement, including when the gate is never reached. ## Verification - The original reconnect regression failed with HTTP 410 before the initial fix and passes with HTTP 503. - The new SQLite-abort regression failed because a rejected archive write still moved the sandbox to `draining`; it now leaves the shutdown record and session status unchanged. - `npm run test -w @open-inspect/control-plane -- --maxWorkers=1` passed: 6,115 tests. - `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1 test/integration/sandbox-shutdown.test.ts test/integration/session-lifecycle.test.ts test/integration/session-batch-archive.test.ts test/integration/websocket-sandbox.test.ts` passed: 75 tests. - `npm run typecheck -w @open-inspect/control-plane` passed. - Targeted ESLint, Prettier, and commit hooks passed. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/6d30e15bbef91458fcc84af0a844cc3f)* ## Summary by CodeRabbit * **Bug Fixes** * Session archiving now proceeds while sandbox preservation is underway, while still confirming the archived status before completing. * Sandbox reconnect attempts receive a temporary “Sandbox is being saved” response while archiving is pending. * Reconnect attempts for archived sessions with missing or legacy shutdown records receive a “Session is terminal” response. * If updating a session’s local status fails during archiving, sandbox shutdown state remains unchanged. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../session-lifecycle.handler.test.ts | 36 ++++-- .../handlers/session-lifecycle.handler.ts | 8 +- .../session/session-status-service.test.ts | 22 ++++ .../src/session/session-status-service.ts | 35 +++-- .../test/integration/sandbox-shutdown.test.ts | 120 ++++++++++++++++++ 5 files changed, 197 insertions(+), 24 deletions(-) diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts index 026df7ad7e..5ab14cb33a 100644 --- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts @@ -87,11 +87,13 @@ function createHandler() { getSandbox, } as unknown as SandboxRepository; const transition = vi.fn<(status: SessionRow["status"]) => Promise>(); + const beginTransition = vi.fn(); const confirmIndexStatus = vi.fn<() => Promise>(); const repairIndexStatus = vi.fn<() => Promise>(); const settleFromMessageState = vi.fn<() => Promise>(); const statusService = { transition, + beginTransition, repairIndexStatus, confirmIndexStatus, settleFromMessageState, @@ -128,6 +130,7 @@ function createHandler() { getSession, getSandbox, transition, + beginTransition, repairIndexStatus, confirmIndexStatus, settleFromMessageState, @@ -280,9 +283,9 @@ describe("SessionLifecycleHandler", () => { }); it("archives successfully without participant authorization", async () => { - const { handler, getSession, transition, preserveForArchive } = createHandler(); + const { handler, getSession, beginTransition, preserveForArchive } = createHandler(); getSession.mockReturnValue(createSession()); - transition.mockResolvedValue(true); + beginTransition.mockResolvedValue(true); const response = await handler.archive( new Request("http://internal/internal/archive", { @@ -294,14 +297,28 @@ describe("SessionLifecycleHandler", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ status: "archived", outcome: "archived" }); - expect(transition).toHaveBeenCalledWith("archived"); + expect(beginTransition).toHaveBeenCalledWith("archived"); // An archived session's reconnects are refused, so its sandbox is saved now. expect(preserveForArchive).toHaveBeenCalledOnce(); - expect(transition.mock.invocationCallOrder[0]).toBeLessThan( + expect(beginTransition.mock.invocationCallOrder[0]).toBeLessThan( preserveForArchive.mock.invocationCallOrder[0] ); }); + it("does not preserve when the synchronous local transition fails", async () => { + const { handler, getSession, beginTransition, preserveForArchive, confirmIndexStatus } = + createHandler(); + getSession.mockReturnValue(createSession()); + beginTransition.mockImplementation(() => { + throw new Error("local status write failed"); + }); + + await expect(handler.archive()).rejects.toThrow("local status write failed"); + + expect(preserveForArchive).not.toHaveBeenCalled(); + expect(confirmIndexStatus).not.toHaveBeenCalled(); + }); + it("archives a draft that was never prompted", async () => { const { handler, getSession, transition } = createHandler(); getSession.mockReturnValue(createSession({ status: "created" })); @@ -405,7 +422,8 @@ describe("SessionLifecycleHandler", () => { }); it("returns 409 when archiving a session with queued work", async () => { - const { handler, getSession, repository, transition, preserveForArchive } = createHandler(); + const { handler, getSession, repository, beginTransition, preserveForArchive } = + createHandler(); getSession.mockReturnValue(createSession()); repository.getPendingOrProcessingCount.mockReturnValue(1); @@ -417,12 +435,12 @@ describe("SessionLifecycleHandler", () => { ); expect(response.status).toBe(409); - expect(transition).not.toHaveBeenCalled(); + expect(beginTransition).not.toHaveBeenCalled(); expect(preserveForArchive).not.toHaveBeenCalled(); }); it("returns 409 when archiving a cancelled session", async () => { - const { handler, getSession, transition } = createHandler(); + const { handler, getSession, beginTransition } = createHandler(); getSession.mockReturnValue(createSession({ status: "cancelled" })); const response = await handler.archive( @@ -433,7 +451,7 @@ describe("SessionLifecycleHandler", () => { ); expect(response.status).toBe(409); - expect(transition).not.toHaveBeenCalled(); + expect(beginTransition).not.toHaveBeenCalled(); }); // Unarchive must not assert a status of its own. Forcing "active" left a @@ -520,7 +538,7 @@ describe("canonical archive outcomes", () => { expect(await response.json()).toMatchObject({ outcome: status === "cancelled" ? "skipped_cancelled" : "skipped_queued_work", }); - expect(h.transition).not.toHaveBeenCalled(); + expect(h.beginTransition).not.toHaveBeenCalled(); } ); it("returns retryable failure when the projection cannot be confirmed", async () => { diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts index 2f0c659140..5f4f70494b 100644 --- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts +++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts @@ -162,8 +162,12 @@ export class SessionLifecycleHandler { }); } - await this.statusService.transition("archived"); - await this.sandboxLifecycle.preserveForArchive(); + // Commit archived before starting preservation, but do not await its index + // projection: reconnects must see draining in the same turn. + await Promise.all([ + this.statusService.beginTransition("archived"), + this.sandboxLifecycle.preserveForArchive(), + ]); try { await this.statusService.confirmIndexStatus("archived"); } catch { diff --git a/packages/control-plane/src/session/session-status-service.test.ts b/packages/control-plane/src/session/session-status-service.test.ts index f80ab09ee4..05acdac416 100644 --- a/packages/control-plane/src/session/session-status-service.test.ts +++ b/packages/control-plane/src/session/session-status-service.test.ts @@ -200,6 +200,16 @@ describe("SessionStatusService.transition", () => { expect(h.broadcast).toHaveBeenCalledWith({ type: "session_status", status: "active" }); }); + it("keeps local write failures as promise rejections", async () => { + const h = harness(); + h.repository.updateSessionStatus.mockImplementation(() => { + throw new Error("local status write failed"); + }); + + await expect(h.service.transition("archived")).rejects.toThrow("local status write failed"); + expect(h.statusProjection.project).not.toHaveBeenCalled(); + }); + it("short-circuits on same status: refreshes the index but neither persists nor broadcasts", async () => { const h = harness({ session: createSession({ status: "active" }) }); @@ -415,6 +425,18 @@ describe("SessionStatusService.transition", () => { }); }); +describe("SessionStatusService.beginTransition", () => { + it("throws a local write failure synchronously without starting projection", () => { + const h = harness(); + h.repository.updateSessionStatus.mockImplementation(() => { + throw new Error("local status write failed"); + }); + + expect(() => h.service.beginTransition("archived")).toThrow("local status write failed"); + expect(h.statusProjection.project).not.toHaveBeenCalled(); + }); +}); + describe("SessionStatusService.cancel", () => { it("closes local status and unfinished messages before publishing projections", async () => { const h = harness({ session: createSession({ status: "active" }) }); diff --git a/packages/control-plane/src/session/session-status-service.ts b/packages/control-plane/src/session/session-status-service.ts index 7544d7f4f9..525cf84eca 100644 --- a/packages/control-plane/src/session/session-status-service.ts +++ b/packages/control-plane/src/session/session-status-service.ts @@ -53,36 +53,45 @@ export class SessionStatusService { * refreshed in the same-status case). */ async transition(status: SessionStatus): Promise { + return this.beginTransition(status); + } + + /** + * Commit local status synchronously, then return its projection promise. + * A local write failure throws before callers can start dependent work. + */ + beginTransition(status: SessionStatus): Promise { const session = this.repository.getSession(); - if (!session) return false; + if (!session) return Promise.resolve(false); const publicSessionId = this.getPublicSessionId(session); if (session.status === status) { - await this.syncSessionIndexStatusAndAdmission( + return this.syncSessionIndexStatusAndAdmission( publicSessionId, status, session.updated_at, session.status_revision - ).catch((error) => - this.logSessionIndexStatusSyncError(publicSessionId, status, session.updated_at, error) - ); - if (isTurnSettled(status)) { - this.syncSessionMetrics(publicSessionId); - } - return false; + ) + .catch((error) => + this.logSessionIndexStatusSyncError(publicSessionId, status, session.updated_at, error) + ) + .then(() => { + if (isTurnSettled(status)) { + this.syncSessionMetrics(publicSessionId); + } + return false; + }); } const updatedAt = Math.max(Date.now(), session.updated_at + 1); this.repository.updateSessionStatus(session.id, status, updatedAt); - await this.projectTransition( + return this.projectTransition( session, publicSessionId, status, updatedAt, session.status_revision + 1 - ); - - return true; + ).then(() => true); } /** diff --git a/packages/control-plane/test/integration/sandbox-shutdown.test.ts b/packages/control-plane/test/integration/sandbox-shutdown.test.ts index 73ee04e0be..63b4f2cebc 100644 --- a/packages/control-plane/test/integration/sandbox-shutdown.test.ts +++ b/packages/control-plane/test/integration/sandbox-shutdown.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { env } from "cloudflare:test"; import type { SessionDO } from "../../src/cloudflare/durable-object"; +import { SessionStatusProjectionStore } from "../../src/db/session-status-projection-store"; import { DEFAULT_LIFECYCLE_CONFIG, SandboxLifecycleManager, @@ -304,6 +305,125 @@ describe("sandbox graceful shutdown wiring", () => { expect(await response.text()).toBe("Sandbox is being saved"); }); + it("does not start preservation when the local archive status write fails", async () => { + const { stub } = await initNamedSession(`archive-status-write-failure-${Date.now()}`); + await seedSandboxAuth(stub, { authToken: AUTH_TOKEN, sandboxId: SANDBOX_ID, status: "ready" }); + await seedShutdown(stub, { + generationReady: true, + runtimeReady: true, + protocolVersion: 1, + lifecyclePolicy: "confirmed", + }); + await queryDO(stub, "UPDATE session SET status = 'completed'"); + const shutdownBefore = await readShutdown(stub); + + await runInSessionDO(stub, async (instance, state) => { + state.storage.sql.exec( + `CREATE TRIGGER fail_archive_status BEFORE UPDATE OF status ON session + WHEN NEW.status = 'archived' + BEGIN SELECT RAISE(ABORT, 'injected archive status write failure'); END` + ); + try { + await expect(componentsOf(instance).sessionLifecycleHandler.archive()).rejects.toThrow( + "injected archive status write failure" + ); + } finally { + state.storage.sql.exec("DROP TRIGGER fail_archive_status"); + } + }); + + expect(await readShutdown(stub)).toEqual(shutdownBefore); + expect(await queryDO(stub, "SELECT status FROM session")).toEqual([{ status: "completed" }]); + }); + + it("keeps an archived sandbox alive while the status projection is pending", async () => { + const name = `archive-pending-projection-${Date.now()}`; + const { stub } = await initNamedSession(name); + await seedSandboxAuth(stub, { authToken: AUTH_TOKEN, sandboxId: SANDBOX_ID, status: "ready" }); + await runInSessionDO(stub, (_instance, state) => { + state.storage.sql.exec("UPDATE sandbox SET modal_object_id = 'sb-live'"); + }); + await seedShutdown(stub, { + providerObjectId: "sb-live", + generationReady: true, + runtimeReady: true, + protocolVersion: 1, + lifecyclePolicy: "confirmed", + }); + await queryDO(stub, "UPDATE session SET status = 'completed'"); + + // Create and release the gate inside the DO to retain its I/O context. + let releaseProjection: (() => void) | undefined; + await runInSessionDO(stub, () => { + const project = SessionStatusProjectionStore.prototype.project; + vi.spyOn(SessionStatusProjectionStore.prototype, "project").mockImplementationOnce( + async function (this: SessionStatusProjectionStore, ...args) { + await new Promise((resolve) => { + releaseProjection = resolve; + }); + return project.call(this, ...args); + } + ); + }); + + const archiving = stub.fetch("http://internal/internal/archive", { method: "POST" }); + const archiveSettled = archiving.catch(() => undefined); + try { + await vi.waitFor(() => expect(releaseProjection).toBeTypeOf("function")); + expect(await queryDO(stub, "SELECT status FROM session")).toEqual([{ status: "archived" }]); + const { ws, response } = await openSandboxWs(name, { + authToken: AUTH_TOKEN, + sandboxId: SANDBOX_ID, + }); + expect(ws).toBeNull(); + expect(response.status).toBe(503); + expect(await response.text()).toBe("Sandbox is being saved"); + expect(await readShutdown(stub)).toMatchObject({ + phase: "draining", + reason: "session_archived", + }); + } finally { + await runInSessionDO(stub, () => { + releaseProjection?.(); + vi.restoreAllMocks(); + }); + await archiveSettled; + } + + expect((await archiving).status).toBe(200); + expect(await readShutdown(stub)).toMatchObject({ + phase: "draining", + reason: "session_archived", + }); + }); + + it.each(["missing", "legacy"])( + "tells an archived sandbox to exit when its shutdown record is %s", + async (policy) => { + const name = `archive-unmanaged-${policy}-${Date.now()}`; + const { stub } = await initNamedSession(name); + await seedSandboxAuth(stub, { + authToken: AUTH_TOKEN, + sandboxId: SANDBOX_ID, + status: "ready", + }); + if (policy === "legacy") { + await seedShutdown(stub, { lifecyclePolicy: "legacy" }); + } + await queryDO(stub, "UPDATE session SET status = 'completed'"); + + const archived = await stub.fetch("http://internal/internal/archive", { method: "POST" }); + expect(archived.status).toBe(200); + const { ws, response } = await openSandboxWs(name, { + authToken: AUTH_TOKEN, + sandboxId: SANDBOX_ID, + }); + expect(ws).toBeNull(); + expect(response.status).toBe(410); + expect(await response.text()).toBe("Session is terminal"); + } + ); + it("preserves a completed session status when shutdown begins between prompts", async () => { const name = `shutdown-completed-status-${Date.now()}`; const { stub } = await initNamedSession(name); From 1c691ec0b4dabb0a29e23942b318a02cc4863d8a Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Fri, 2 Oct 2026 23:33:23 +0400 Subject: [PATCH 04/68] fix(control-plane): keep a reserved capture while its own deadline holds (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ImageBuildReaper.reconcileUnresolvedOperations` can clear the provider-operation reference of a capture that is still running. ## Mechanism The `absent` branch settles on a `created_at`-anchored estimate: ```ts if (outcome.type === "absent" && now - row.created_at <= DEFAULT_STALE_BUILD_MAX_AGE_MS) { ``` while the row carries the exact bound the comment directly above it appeals to. The two are anchored to **different clocks**: - `DEFAULT_STALE_BUILD_MAX_AGE_MS` is 75 min measured from `created_at` = `registerBuild` (`src/image-builds/maintenance.ts:20-21`, `src/image-builds/timeouts.ts:6-13`). Its own comment (`maintenance.ts:14-18`) says the clock "starts at row registration, not sandbox start, so dispatch latency and provider queueing eat into the grace budget" — that budget is `IMAGE_BUILD_STALE_DISPATCH_GRACE_MS`, 5 min. - `provider_operation_deadline_at` is stamped at **reservation** time against the source's remaining lifetime (`src/image-builds/daytona-adapter.ts:209-223,333-339`), and is documented as a "Fixed wall-clock deadline (ms) for that operation; never extended by a retry" (`src/db/image-build-finalization.ts:73-74`). The capture attempt itself treats exhaustion as `now >= operation.deadlineAt` (`daytona-adapter.ts:305-313`). A build dispatched later than the 5-minute registration grace allows therefore holds a live deadline that outlasts the estimate. The reaper could not even see it: `UnresolvedProviderOperationRow` had no such field and `UNRESOLVED_PROVIDER_OPERATIONS_SQL` did not select the column (`src/db/image-builds.ts:179-185,227-233`). A row reaches that query while its deadline is still live via `supersedeScopeBuilds`, which flips `building` → `superseded` with no regard for a reserved operation (`src/db/image-builds.ts:786-793`), or via `markFailed`, which deliberately leaves the ref and deadline in place — unlike `recordArtifact` and `quarantineArtifact`, which NULL them (`src/db/image-build-finalization.ts:399-414` vs `:316-322,431-438`). Clearing the reference there drops the only handle to a capture still running. ## Fix Select the persisted deadline and retain the obligation while **either** bound still holds. A row that recorded no deadline (`?? 0`) is treated as already exhausted, so the estimate alone decides exactly as before — no behaviour change for rows predating the column. ## Verification `npm test -w @open-inspect/control-plane -- src/image-builds/reaper.test.ts` → 19 passed. The three new cases were falsified against the unfixed source: older than the max age but within a live deadline is **not** reaped (this one fails without the fix), an expired deadline **is** reaped, and a null deadline keeps today's behaviour. ## Summary by CodeRabbit - **Bug Fixes** - Prevented operations from being settled as absent before their reserved capture deadline has elapsed. - Operations without a recorded deadline continue to use the existing stale-age handling. - Improved reconciliation behavior and reporting for operations that may still be running. Co-authored-by: Cole Murray --- packages/control-plane/src/db/image-builds.ts | 4 +- .../src/image-builds/reaper.test.ts | 46 ++++++++++++++++++- .../control-plane/src/image-builds/reaper.ts | 20 ++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/control-plane/src/db/image-builds.ts b/packages/control-plane/src/db/image-builds.ts index 2b56b5d270..76fafded75 100644 --- a/packages/control-plane/src/db/image-builds.ts +++ b/packages/control-plane/src/db/image-builds.ts @@ -181,6 +181,8 @@ export interface UnresolvedProviderOperationRow { provider: ImageBuildProvider; provider_session_id: string | null; provider_operation_ref: string; + /** Fixed wall-clock deadline (ms) the reservation recorded; null before it existed. */ + provider_operation_deadline_at: number | null; created_at: number; } @@ -225,7 +227,7 @@ export const UNBOUND_SOURCE_INTENTS_SQL = `SELECT id, provider, created_at * still listed here is an obligation nothing else on the row records. */ export const UNRESOLVED_PROVIDER_OPERATIONS_SQL = `SELECT id, provider, provider_session_id, - provider_operation_ref, created_at + provider_operation_ref, provider_operation_deadline_at, created_at FROM image_builds WHERE status IN ('failed', 'superseded') AND provider_operation_ref IS NOT NULL diff --git a/packages/control-plane/src/image-builds/reaper.test.ts b/packages/control-plane/src/image-builds/reaper.test.ts index 417368d2c9..55da17b81d 100644 --- a/packages/control-plane/src/image-builds/reaper.test.ts +++ b/packages/control-plane/src/image-builds/reaper.test.ts @@ -272,11 +272,16 @@ describe("ImageBuildReaper unbound source recovery", () => { }); describe("ImageBuildReaper orphan operation reconciliation", () => { - const operation = (id: string, createdAt: number = conclusivelyAbsentAt) => ({ + const operation = ( + id: string, + createdAt: number = conclusivelyAbsentAt, + deadlineAt: number | null = null + ) => ({ id, provider: "daytona" as const, provider_session_id: "sandbox-7", provider_operation_ref: `oi-image-${id}`, + provider_operation_deadline_at: deadlineAt, created_at: createdAt, }); @@ -297,7 +302,11 @@ describe("ImageBuildReaper orphan operation reconciliation", () => { it("settles an absent operation only once a capture can no longer be running", async () => { const store = createStore(); - store.listUnresolvedOperations.mockResolvedValue([operation("b-1")]); + // No deadline recorded — every row written before the reservation column + // existed — so the registration-anchored estimate is the only bound. + store.listUnresolvedOperations.mockResolvedValue([ + operation("b-1", conclusivelyAbsentAt, null), + ]); const adapter = createRecoverableAdapter(); adapter.reconcileOrphanOperation.mockResolvedValue({ type: "absent" }); const { reaper } = createReaper({ store, adapter }); @@ -324,6 +333,39 @@ describe("ImageBuildReaper orphan operation reconciliation", () => { expect(store.clearProviderOperation).not.toHaveBeenCalled(); }); + it("keeps an absent operation whose recorded deadline has not passed", async () => { + const store = createStore(); + // The registration-anchored estimate has lapsed, but the capture was + // reserved late enough that its own deadline is still live: the source it + // reads is still there to publish a snapshot nothing else would name. + store.listUnresolvedOperations.mockResolvedValue([ + operation("b-1", conclusivelyAbsentAt, now + 1), + ]); + const adapter = createRecoverableAdapter(); + adapter.reconcileOrphanOperation.mockResolvedValue({ type: "absent" }); + const { reaper } = createReaper({ store, adapter }); + + const result = await reaper.reconcileUnresolvedOperations(ctx, now); + + expect(result).toEqual({ reconciled: 0, retained: 1 }); + expect(store.clearProviderOperation).not.toHaveBeenCalled(); + }); + + it("settles an absent operation once its recorded deadline has passed", async () => { + const store = createStore(); + // Exhausted at the deadline, not after it, exactly as the attempt that + // reserved it gives up. + store.listUnresolvedOperations.mockResolvedValue([operation("b-1", conclusivelyAbsentAt, now)]); + const adapter = createRecoverableAdapter(); + adapter.reconcileOrphanOperation.mockResolvedValue({ type: "absent" }); + const { reaper } = createReaper({ store, adapter }); + + const result = await reaper.reconcileUnresolvedOperations(ctx, now); + + expect(result).toEqual({ reconciled: 1, retained: 0 }); + expect(store.clearProviderOperation).toHaveBeenCalledWith("b-1", "oi-image-b-1"); + }); + it("keeps an operation that has not settled", async () => { const store = createStore(); store.listUnresolvedOperations.mockResolvedValue([operation("b-1")]); diff --git a/packages/control-plane/src/image-builds/reaper.ts b/packages/control-plane/src/image-builds/reaper.ts index 680b8df9d5..a1c47973c4 100644 --- a/packages/control-plane/src/image-builds/reaper.ts +++ b/packages/control-plane/src/image-builds/reaper.ts @@ -198,9 +198,23 @@ export class ImageBuildReaper { // Finding nothing under the reserved name is not yet evidence that // nothing was produced: the record can appear well after the capture // was accepted. Only once the source it reads has certainly outlived - // its hard lifetime can a later artifact no longer arrive, which is - // the same bound an unbound create intent settles on. - if (outcome.type === "absent" && now - row.created_at <= DEFAULT_STALE_BUILD_MAX_AGE_MS) { + // its hard lifetime can a later artifact no longer arrive. + // + // The obligation therefore survives while either bound on that + // lifetime still holds. `created_at` is the estimate an unbound + // create intent settles on, anchored at registration; the row's + // deadline is the exact bound, fixed against the source's remaining + // lifetime when the capture was reserved. A build dispatched later + // than the age rule's registration grace allows holds a live deadline + // past it, and clearing the reference there would drop the only + // handle to a capture still running. A row that recorded no deadline + // is treated as already exhausted, so the estimate alone decides + // exactly as before. + if ( + outcome.type === "absent" && + (now - row.created_at <= DEFAULT_STALE_BUILD_MAX_AGE_MS || + now < (row.provider_operation_deadline_at ?? 0)) + ) { this.retainOperation(row, result, ctx, now, "capture_may_still_be_running"); return; } From 864525d96e38aae186d3fe0dce3c57278e266ff1 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 17:39:15 -0700 Subject: [PATCH 05/68] feat: add session source and user attribution analytics (#2218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add a **Session origins** card directly below the Analytics session summary, showing ranked source counts and shares for Slack, GitHub, Linear, user/app, agent sub-sessions, and automations. - Select a source to see its attributed users, session counts, and within-source shares; reset to all sources or change the existing date/scope controls. - Extend the existing dashboard snapshot with source-by-user counts in its single database batch. Reuse date-window, source-scope, and visibility predicates; private sessions remain excluded. - Preserve canonical user identities, separate same-name users, and include legacy-login and unknown-user buckets. Explain attribution limitations in the UI and correct outdated summary/scope copy. - Include responsive layouts, accessible source controls, a keyboard-focusable user list, loading/empty/cached states, and selection resets. ## Scope and Data Semantics This measures **session creation origins**, not subsequent message activity. Existing attribution can identify integration actors or automation owners rather than a direct human creator. Historical source defaults remain under User / app; no speculative backfill or schema migration is introduced. The existing Human default scope is retained; All includes agent and automation sessions. ## Verification - Shared package build passed. - Control-plane and web typechecks passed. - Repository ESLint and SQL portability checks passed. - Targeted web analytics tests: **38 passed**. - Targeted control-plane analytics tests: **54 passed**. - Real-D1 analytics integration tests: **16 passed**, including source/user grouping, all scopes, exact date boundaries, legacy/unknown attribution, and visibility. - Browser-tested the real `/analytics` page with mocked auth/API responses at **1440×1100** and **390×844**: source filtering, All sources reset, date/scope resets, and all six source categories. No browser errors. Backend correctness was checked separately with real D1. - Reviewed changes and fixed keyboard scrolling and stale-source-selection issues. `git diff --check` passed. ## Visual Evidence Viewport screenshots uploaded to the Open-Inspect session (mocked data, `http://localhost:3000/analytics`): - Desktop, all sources, 1440×1100: artifact `a10c57ac73c91449d85436c35bad1030` - Mobile, Slack selected, 390×844: artifact `f82481b838e0b10c8e664783129d2d62` --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/6b804adda7bf204945bdb55de6c5cc80)* ## Summary by CodeRabbit * **New Features** * Added a Session Origins card to analytics, showing session counts and percentages by source and attributed user. * Added source filtering, with the selection resetting when the date range or scope changes and reverting to all sources if the selected source is no longer available. * Session origin data follows the selected date range and scope, and includes unattributed sessions. * Expanded active-user attribution to include legacy logins. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../src/db/analytics-dashboard-store.test.ts | 21 +- .../src/db/analytics-dashboard-store.ts | 3 + .../src/db/analytics-store.test.ts | 48 +++++ .../control-plane/src/db/analytics-store.ts | 57 ++++- .../src/routes/analytics.test.ts | 8 +- .../test/integration/analytics.test.ts | 114 ++++++++++ packages/shared/src/types/analytics.ts | 9 + packages/shared/src/types/index.ts | 1 + .../(app)/(sidebar)/analytics/page.test.tsx | 27 +++ .../app/(app)/(sidebar)/analytics/page.tsx | 11 +- .../analytics/session-origins-card.test.tsx | 122 +++++++++++ .../analytics/session-origins-card.tsx | 196 ++++++++++++++++++ .../components/analytics/summary-cards.tsx | 2 +- packages/web/src/hooks/use-analytics.test.tsx | 2 + packages/web/src/hooks/use-analytics.ts | 1 + 15 files changed, 610 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/components/analytics/session-origins-card.test.tsx create mode 100644 packages/web/src/components/analytics/session-origins-card.tsx diff --git a/packages/control-plane/src/db/analytics-dashboard-store.test.ts b/packages/control-plane/src/db/analytics-dashboard-store.test.ts index a5bf5c7f06..ee6c03cd3e 100644 --- a/packages/control-plane/src/db/analytics-dashboard-store.test.ts +++ b/packages/control-plane/src/db/analytics-dashboard-store.test.ts @@ -43,7 +43,12 @@ describe("AnalyticsDashboardStore", () => { }, ], }; - if (index === 18) + if (index === 8) + return { + ...emptyResult(), + results: [{ source: "agent", user_key: "user-1", display_name: "Ada", sessions: 2 }], + }; + if (index === 19) return { ...emptyResult(), results: [ @@ -101,16 +106,21 @@ describe("AnalyticsDashboardStore", () => { }); expect(batch).toHaveBeenCalledTimes(1); - expect(statements).toHaveLength(19); - expect(batchedStatements).toHaveLength(19); + expect(statements).toHaveLength(20); + expect(batchedStatements).toHaveLength(20); expect(batchedStatements.every((statement) => statements.includes(statement))).toBe(true); - expect(queries[18]).toContain("root.spawn_source IN (?)"); - expect(statements[18].bind).toHaveBeenCalledWith( + expect(queries[19]).toContain("root.spawn_source IN (?)"); + expect(statements[19].bind).toHaveBeenCalledWith( 1_699_395_200_000, 1_700_000_000_000, "agent", DASHBOARD_RUNS_LIMIT ); + expect(batchedStatements[8].bind).toHaveBeenCalledWith( + 1_699_395_200_000, + 1_700_000_000_000, + "agent" + ); expect(response).toMatchObject({ generatedAt: 1_700_000_000_000, window: { @@ -120,6 +130,7 @@ describe("AnalyticsDashboardStore", () => { endAt: 1_700_000_000_000, }, summary: { totalSessions: 0, totalPrs: 0 }, + sessionOrigins: [{ source: "agent", userKey: "user-1", displayName: "Ada", sessions: 2 }], breakdowns: { repository: { entries: [] }, user: { entries: [] }, diff --git a/packages/control-plane/src/db/analytics-dashboard-store.ts b/packages/control-plane/src/db/analytics-dashboard-store.ts index 7c387a7402..27166bf916 100644 --- a/packages/control-plane/src/db/analytics-dashboard-store.ts +++ b/packages/control-plane/src/db/analytics-dashboard-store.ts @@ -51,6 +51,7 @@ export class AnalyticsDashboardStore { harness, automation, billing, + sessionOrigins, ...pullRequestAndRunResults ] = await this.db.batch([ analytics.prepareSummary(sessionFilters), @@ -61,6 +62,7 @@ export class AnalyticsDashboardStore { analytics.prepareBreakdown(sessionFilters, "harness"), analytics.prepareBreakdown(sessionFilters, "automation"), billingStatement, + analytics.prepareSessionOrigins(sessionFilters), ...pullRequestStatements, runs.prepareList({ ...sessionFilters, limit: DASHBOARD_RUNS_LIMIT, orderBy: "cost" }), ]); @@ -78,6 +80,7 @@ export class AnalyticsDashboardStore { }, summary: analytics.decodeSummary(summary), timeseries: analytics.decodeTimeseries(timeseries), + sessionOrigins: analytics.decodeSessionOrigins(sessionOrigins), breakdowns: { repository: analytics.decodeBreakdown(repository, "repo"), user: analytics.decodeBreakdown(user, "user"), diff --git a/packages/control-plane/src/db/analytics-store.test.ts b/packages/control-plane/src/db/analytics-store.test.ts index aeb4bce5c3..7c4ef82a18 100644 --- a/packages/control-plane/src/db/analytics-store.test.ts +++ b/packages/control-plane/src/db/analytics-store.test.ts @@ -128,6 +128,54 @@ describe("AnalyticsStore row decoding", () => { ); }); + it("decodes session origins without merging users or sources", () => { + expect( + store.decodeSessionOrigins( + result([ + { source: "user", user_key: "user-1", display_name: "Ada", sessions: 2 }, + { source: "user", user_key: "user-2", display_name: "Ada", sessions: 1 }, + { source: "slack-bot", user_key: "user-1", display_name: "Ada", sessions: 3 }, + { source: "agent", user_key: "old-login", display_name: "old-login", sessions: 1 }, + { + source: "automation", + user_key: "__unknown__", + display_name: "Unknown user", + sessions: 1, + }, + ]) + ) + ).toEqual([ + { source: "user", userKey: "user-1", displayName: "Ada", sessions: 2 }, + { source: "user", userKey: "user-2", displayName: "Ada", sessions: 1 }, + { source: "slack-bot", userKey: "user-1", displayName: "Ada", sessions: 3 }, + { source: "agent", userKey: "old-login", displayName: "old-login", sessions: 1 }, + { source: "automation", userKey: "__unknown__", displayName: "Unknown user", sessions: 1 }, + ]); + expect(store.decodeSessionOrigins(result([]))).toEqual([]); + }); + + it.each([ + { source: "invalid" }, + { source: null }, + { user_key: null }, + { display_name: undefined }, + { sessions: "2" }, + ])("rejects malformed session origin fields: %j", (overrides) => { + expect(() => + store.decodeSessionOrigins( + result([ + { + source: "user", + user_key: "user-1", + display_name: "Ada", + sessions: 2, + ...overrides, + }, + ]) + ) + ).toThrow("Invalid analytics session origin row"); + }); + it("decodes nullable breakdown fields", () => { expect( store.decodeBreakdown( diff --git a/packages/control-plane/src/db/analytics-store.ts b/packages/control-plane/src/db/analytics-store.ts index f3b762773e..73b9d670d6 100644 --- a/packages/control-plane/src/db/analytics-store.ts +++ b/packages/control-plane/src/db/analytics-store.ts @@ -3,6 +3,7 @@ import type { AnalyticsBreakdownBy, AnalyticsBreakdownEntry, AnalyticsBreakdownResponse, + AnalyticsSessionOriginEntry, AnalyticsSummaryResponse, AnalyticsTimeseriesResponse, AnalyticsScope, @@ -12,7 +13,7 @@ import { ANALYTICS_SCOPE_SPAWN_SOURCES, getCacheHitRatio, } from "@open-inspect/shared/types/analytics"; -import type { SpawnSource } from "@open-inspect/shared/types/sessions"; +import { spawnSourceSchema, type SpawnSource } from "@open-inspect/shared/types/sessions"; import { getModelDisplayName, normalizeModelId, @@ -83,6 +84,13 @@ const timeseriesRowSchema = z.object({ type TimeseriesRow = z.infer; +const sessionOriginRowSchema = z.object({ + source: spawnSourceSchema, + user_key: z.string(), + display_name: z.string(), + sessions: z.number(), +}); + const breakdownRowSchema = tokenRowSchema.extend({ key: z.string().nullable(), display_name: z.string().nullable().optional(), @@ -111,6 +119,9 @@ type BreakdownRow = z.infer; type SqlBreakdownBy = Exclude; const NO_REPOSITORY_ANALYTICS_KEY = "No repository"; +const USER_KEY_EXPRESSION = "COALESCE(s.user_id, NULLIF(s.scm_login, ''), '__unknown__')"; +const USER_DISPLAY_NAME_EXPRESSION = + "COALESCE(MAX(NULLIF(u.display_name, '')), MAX(NULLIF(s.scm_login, '')), 'Unknown user')"; export function mergeBreakdownEntries( entries: AnalyticsBreakdownEntry[], @@ -294,6 +305,46 @@ export class AnalyticsStore { return { series }; } + prepareSessionOrigins(filters: AnalyticsFilters): SqlStatement { + const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source"); + const visible = this.visible("s"); + + return this.db + .prepare( + `WITH filtered_sessions AS ( + SELECT s.spawn_source, s.user_id, s.scm_login, ${USER_KEY_EXPRESSION} AS user_key + FROM sessions s + WHERE s.created_at >= ? AND s.created_at < ? + ${sql} ${visible.sql ? `AND ${visible.sql}` : ""} + ), user_labels AS ( + SELECT s.user_key, ${USER_DISPLAY_NAME_EXPRESSION} AS display_name + FROM filtered_sessions s + LEFT JOIN users u ON s.user_id = u.id + GROUP BY s.user_key + ) + SELECT s.spawn_source AS source, + s.user_key, + u.display_name, + COUNT(*) AS sessions + FROM filtered_sessions s + JOIN user_labels u ON s.user_key = u.user_key + GROUP BY s.spawn_source, s.user_key, u.display_name + ORDER BY sessions DESC, source ASC, u.display_name ASC, s.user_key ASC` + ) + .bind(filters.startAt, filters.endAt, ...binds, ...visible.params); + } + + decodeSessionOrigins(result: SqlResult): AnalyticsSessionOriginEntry[] { + return parseRows(result.results, sessionOriginRowSchema, "analytics session origin row").map( + (row) => ({ + source: row.source, + userKey: row.user_key, + displayName: row.display_name, + sessions: row.sessions, + }) + ); + } + async getBreakdown( filters: AnalyticsFilters, by: AnalyticsBreakdownBy @@ -331,7 +382,7 @@ export class AnalyticsStore { "CASE WHEN s.repo_owner IS NULL OR s.repo_name IS NULL THEN NULL ELSE s.repo_owner || '/' || s.repo_name END"; const groupExpression = { - user: "COALESCE(s.user_id, NULLIF(s.scm_login, ''), '__unknown__')", + user: USER_KEY_EXPRESSION, repo: repoGroupExpression, model: "s.model", harness: "s.harness", @@ -340,7 +391,7 @@ export class AnalyticsStore { }[by]; const displayNameSelect = isUserBreakdown - ? "COALESCE(MAX(NULLIF(u.display_name, '')), MAX(NULLIF(s.scm_login, '')), 'Unknown user') AS display_name," + ? `${USER_DISPLAY_NAME_EXPRESSION} AS display_name,` : by === "automation" ? "MAX(a.name) AS display_name," : "NULL AS display_name,"; diff --git a/packages/control-plane/src/routes/analytics.test.ts b/packages/control-plane/src/routes/analytics.test.ts index f20503cc6e..37b7fa5269 100644 --- a/packages/control-plane/src/routes/analytics.test.ts +++ b/packages/control-plane/src/routes/analytics.test.ts @@ -84,12 +84,16 @@ describe("analytics route handlers", () => { describe("dashboard", () => { it("anchors one shared dashboard window", async () => { - mockDashboardStore.get.mockResolvedValue({ generatedAt: FIXED_NOW }); + const dashboard = { + generatedAt: FIXED_NOW, + sessionOrigins: [{ source: "user", userKey: "user-1", displayName: "Ada", sessions: 2 }], + }; + mockDashboardStore.get.mockResolvedValue(dashboard); const response = await callRoute("GET", "/analytics/dashboard?days=14"); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ generatedAt: FIXED_NOW }); + await expect(response.json()).resolves.toEqual(dashboard); expect(mockDashboardStore.get).toHaveBeenCalledWith({ days: 14, scope: "human", diff --git a/packages/control-plane/test/integration/analytics.test.ts b/packages/control-plane/test/integration/analytics.test.ts index 62efcd4d42..fcbbef433f 100644 --- a/packages/control-plane/test/integration/analytics.test.ts +++ b/packages/control-plane/test/integration/analytics.test.ts @@ -3,6 +3,7 @@ import { createExecutionContext, env } from "cloudflare:test"; import type { AnalyticsBreakdownResponse, AnalyticsDashboardResponse, + AnalyticsSessionOriginEntry, AnalyticsSummaryResponse, AnalyticsTokenTotals, AnalyticsTimeseriesResponse, @@ -13,6 +14,7 @@ import { SessionIndexStore } from "../../src/db/session-index"; import { TeamMembershipStore } from "../../src/db/team-memberships"; import { SessionRunStore } from "../../src/db/session-run-store"; import { AnalyticsStore } from "../../src/db/analytics-store"; +import { AnalyticsDashboardStore } from "../../src/db/analytics-dashboard-store"; import { cleanD1Tables } from "./cleanup"; import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers"; @@ -143,6 +145,9 @@ describe("Analytics API", () => { await serviceFetch("https://test.local/analytics/dashboard?scope=all", owner) ).json(); expect(dashboard.summary).toMatchObject({ totalCost: 3, privateSessionsCostUsd: 7 }); + expect(dashboard.sessionOrigins).toEqual([ + { source: "user", userKey: "alice", displayName: "alice", sessions: 1 }, + ]); const member = await ( await serviceFetch("https://test.local/analytics/summary?scope=all", { as: { userId: "55555555555555555555555555555555", role: "member" }, @@ -222,6 +227,9 @@ describe("Analytics API", () => { inputTokens: 3, privateSessionsCostUsd: null, }); + expect(dashboard.sessionOrigins).toEqual([ + expect.objectContaining({ source: "user", userKey: member, sessions: 2 }), + ]); expect( dashboard.timeseries.series .flatMap((point) => Object.values(point.groups)) @@ -250,6 +258,111 @@ describe("Analytics API", () => { expect( await (await fetchAnalytics("summary?scope=all", "off")).json() ).toMatchObject({ totalSessions: 3, totalCost: 7, privateSessionsCostUsd: null }); + const unenforced = await ( + await fetchAnalytics("dashboard?scope=all", "off") + ).json(); + expect(unenforced.sessionOrigins).toEqual([ + expect.objectContaining({ source: "user", userKey: member, sessions: 3 }), + ]); + }); + + it("groups session origins by source and user identity within the exact scope and date window", async () => { + const endAt = Date.now(); + const startAt = endAt - 7 * 24 * 60 * 60 * 1000; + const index = new SessionIndexStore(env.DB); + await seedUser(env.DB, { id: "origin-user-1", displayName: "Same name" }); + await seedUser(env.DB, { id: "origin-user-2", displayName: "Same name" }); + await seedUser(env.DB, { id: "origin-user-3", displayName: "" }); + + for (const [id, source, userId, scmLogin, createdAt] of [ + ["start", "user", "origin-user-1", "old-login", startAt], + ["renamed", "user", "origin-user-1", "new-login", startAt + 1], + ["same-name", "user", "origin-user-2", "another-login", startAt + 1], + ["slack", "slack-bot", "origin-user-1", "new-login", startAt + 1], + ["linear", "linear-bot", "origin-user-1", "new-login", startAt + 1], + ["github", "github-bot", "origin-user-1", "new-login", startAt + 1], + ["agent", "agent", "origin-user-1", "new-login", startAt + 1], + ["automation", "automation", "origin-user-1", "new-login", endAt - 1], + ["historical", "user", null, "old-login", startAt + 1], + ["historical-repeat", "user", null, "old-login", startAt + 1], + ["historical-other", "user", null, "other-login", startAt + 1], + ["no-name", "user", "origin-user-3", "fallback-login", startAt + 1], + ["no-name-slack", "slack-bot", "origin-user-3", "other-fallback-login", startAt + 1], + ["no-name-before", "user", "origin-user-3", "zzz-outside-window", startAt - 1], + ["unknown-null", "user", null, null, startAt + 1], + ["unknown-empty", "user", null, "", startAt + 1], + ["before", "user", "origin-user-1", "new-login", startAt - 1], + ["end", "user", "origin-user-1", "new-login", endAt], + ["future", "user", "origin-user-1", "new-login", endAt + 1], + ] as const) { + await seedSession(index, { + id, + spawnSource: source, + userId, + scmLogin, + createdAt, + updatedAt: endAt, + repoOwner: null, + repoName: null, + status: "completed", + totalCost: 0, + activeDurationMs: 0, + messageCount: 0, + prCount: 0, + }); + } + + const origins: AnalyticsSessionOriginEntry[] = [ + { source: "user", userKey: "origin-user-1", displayName: "Same name", sessions: 2 }, + { source: "user", userKey: "origin-user-2", displayName: "Same name", sessions: 1 }, + { source: "slack-bot", userKey: "origin-user-1", displayName: "Same name", sessions: 1 }, + { source: "linear-bot", userKey: "origin-user-1", displayName: "Same name", sessions: 1 }, + { source: "github-bot", userKey: "origin-user-1", displayName: "Same name", sessions: 1 }, + { source: "agent", userKey: "origin-user-1", displayName: "Same name", sessions: 1 }, + { source: "automation", userKey: "origin-user-1", displayName: "Same name", sessions: 1 }, + { source: "user", userKey: "old-login", displayName: "old-login", sessions: 2 }, + { source: "user", userKey: "other-login", displayName: "other-login", sessions: 1 }, + { + source: "user", + userKey: "origin-user-3", + displayName: "other-fallback-login", + sessions: 1, + }, + { + source: "slack-bot", + userKey: "origin-user-3", + displayName: "other-fallback-login", + sessions: 1, + }, + { source: "user", userKey: "__unknown__", displayName: "Unknown user", sessions: 2 }, + ]; + const dashboard = new AnalyticsDashboardStore(env.DB, { kind: "service", teamId: null }, "on"); + for (const [scope, sources] of [ + ["human", ["user", "slack-bot", "linear-bot", "github-bot"]], + ["agent", ["agent"]], + ["automation", ["automation"]], + ["all", ["user", "slack-bot", "linear-bot", "github-bot", "agent", "automation"]], + ] as const) { + const snapshot = await dashboard.get({ days: 7, startAt, endAt, scope }); + const expected = origins.filter((entry) => sources.some((source) => source === entry.source)); + expect(snapshot.sessionOrigins).toHaveLength(expected.length); + expect(snapshot.sessionOrigins).toEqual(expect.arrayContaining(expected)); + expect(snapshot.summary.totalSessions).toBe( + expected.reduce((sum, entry) => sum + entry.sessions, 0) + ); + for (const user of snapshot.breakdowns.user.entries) { + for (const origin of snapshot.sessionOrigins.filter( + (entry) => entry.userKey === user.key + )) { + expect(origin.displayName).toBe(user.displayName); + } + expect(user.sessions).toBe( + snapshot.sessionOrigins + .filter((entry) => entry.userKey === user.key) + .reduce((sum, entry) => sum + entry.sessions, 0) + ); + } + } }); it("sums token totals across sessions and provider merges without excluding zero-token history", async () => { @@ -378,6 +491,7 @@ describe("Analytics API", () => { expect(body).toMatchObject({ summary: { totalSessions: 0, totalPrs: 0 }, timeseries: { series: [] }, + sessionOrigins: [], breakdowns: { repository: { entries: [] }, user: { entries: [] }, diff --git a/packages/shared/src/types/analytics.ts b/packages/shared/src/types/analytics.ts index c8258fbb75..aec376bab8 100644 --- a/packages/shared/src/types/analytics.ts +++ b/packages/shared/src/types/analytics.ts @@ -107,6 +107,14 @@ export interface AnalyticsBreakdownResponse { entries: AnalyticsBreakdownEntry[]; } +export interface AnalyticsSessionOriginEntry { + source: SpawnSource; + /** Canonical user ID, legacy SCM login, or __unknown__; not necessarily a human creator. */ + userKey: string; + displayName: string; + sessions: number; +} + /** Sessions in one root_session_id family, attributed to its visible root. */ export interface SessionRun { rootSessionId: string; @@ -221,6 +229,7 @@ export interface AnalyticsDashboardResponse { }; summary: AnalyticsSummaryResponse; timeseries: AnalyticsTimeseriesResponse; + sessionOrigins: AnalyticsSessionOriginEntry[]; breakdowns: { repository: AnalyticsBreakdownResponse; user: AnalyticsBreakdownResponse; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index fb09614434..3431c4ce48 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -410,6 +410,7 @@ export type { AnalyticsTimeseriesResponse, AnalyticsBreakdownEntry, AnalyticsBreakdownResponse, + AnalyticsSessionOriginEntry, SessionRun, AnalyticsRunsResponse, AnalyticsPullRequestFunnel, diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx index c582caa8c8..281b8757b6 100644 --- a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx @@ -223,6 +223,33 @@ function getUserRows() { } describe("AnalyticsPage", () => { + it("renders session origins and resets the local source selection on range and scope changes", async () => { + const user = userEvent.setup(); + renderPage(); + mockUseAnalyticsDashboard.mockReturnValue({ + summary, + sessionOrigins: [{ source: "slack-bot", userKey: "zoe", displayName: "Zoe", sessions: 8 }], + loading: false, + }); + await user.click(screen.getByRole("radio", { name: "7d" })); + let origins = within(screen.getByRole("region", { name: "Session origins" })); + await user.click(origins.getByRole("button", { name: /Slack/ })); + expect(origins.getByRole("button", { name: /Slack/ })).toHaveAttribute("aria-pressed", "true"); + await user.click(screen.getByRole("radio", { name: "14d" })); + origins = within(screen.getByRole("region", { name: "Session origins" })); + expect(origins.getByRole("button", { name: "All sources" })).toHaveAttribute( + "aria-pressed", + "true" + ); + await user.click(origins.getByRole("button", { name: /Slack/ })); + await user.click(screen.getByRole("radio", { name: "All" })); + origins = within(screen.getByRole("region", { name: "Session origins" })); + expect(origins.getByRole("button", { name: "All sources" })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); + it("shows automation only for automation and all scopes and orders the new views", async () => { const user = userEvent.setup(); renderPage(); diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx index 7b4694ef49..4229eeaf86 100644 --- a/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx @@ -17,6 +17,7 @@ import { AnalyticsPullRequestRepoTable } from "@/components/analytics/pull-reque import { AnalyticsRepoBarChart } from "@/components/analytics/repo-bar-chart"; import { AnalyticsRunsTable } from "@/components/analytics/runs-table"; import { AnalyticsSummaryCards } from "@/components/analytics/summary-cards"; +import { AnalyticsSessionOriginsCard } from "@/components/analytics/session-origins-card"; import { AnalyticsTimeseriesChart } from "@/components/analytics/timeseries-chart"; import { AnalyticsTokenCards } from "@/components/analytics/token-cards"; import { AnalyticsUserTable } from "@/components/analytics/user-table"; @@ -44,6 +45,7 @@ export default function AnalyticsPage() { const [sortDirection, setSortDirection] = useState("desc"); const { summary, + sessionOrigins, timeseries, repoBreakdown, userBreakdown, @@ -64,6 +66,7 @@ export default function AnalyticsPage() { ); const hasCachedData = Boolean( summary || + sessionOrigins?.length || timeseries?.series?.length || repoBreakdown?.entries?.length || sortedUserEntries?.length || @@ -189,7 +192,7 @@ export default function AnalyticsPage() {
- Human: sessions people started, including via Slack, Linear and GitHub. Agents: + Human: user/app and integration sessions (Slack, Linear and GitHub). Agents: sessions spawned by other sessions. Automations: sessions started by automations. All: every session.
@@ -208,6 +211,12 @@ export default function AnalyticsPage() { <> + +
diff --git a/packages/web/src/components/analytics/session-origins-card.test.tsx b/packages/web/src/components/analytics/session-origins-card.test.tsx new file mode 100644 index 0000000000..dda7d3c7d1 --- /dev/null +++ b/packages/web/src/components/analytics/session-origins-card.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +/// + +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { AnalyticsSessionOriginEntry } from "@open-inspect/shared/types/analytics"; +import { AnalyticsSessionOriginsCard } from "./session-origins-card"; + +expect.extend(matchers); +afterEach(cleanup); + +const entries: AnalyticsSessionOriginEntry[] = [ + { source: "slack-bot", userKey: "alice", displayName: "Alice", sessions: 6 }, + { source: "slack-bot", userKey: "bob", displayName: "Bob", sessions: 2 }, + { source: "github-bot", userKey: "alice", displayName: "Alice", sessions: 1 }, + { source: "user", userKey: "__unknown__", displayName: "Unknown user", sessions: 1 }, +]; + +describe("AnalyticsSessionOriginsCard", () => { + it("ranks sources and aggregates users across sources without losing unknown attribution", () => { + render(); + const slack = screen.getByRole("button", { name: /Slack/ }); + expect(within(slack).getByText("8")).toBeInTheDocument(); + expect(within(slack).getByText("80%")).toBeInTheDocument(); + const users = within(screen.getByRole("list", { name: "Users for All sources" })).getAllByRole( + "listitem" + ); + expect(within(users[0]).getByText("Alice")).toBeInTheDocument(); + expect(screen.getByRole("list", { name: "Users for All sources" })).toHaveAttribute( + "tabindex", + "0" + ); + expect(within(users[0]).getByText("7")).toBeInTheDocument(); + expect(within(users[0]).getByText("70%")).toBeInTheDocument(); + expect(within(users[2]).getByText("No recorded user")).toBeInTheDocument(); + }); + + it("filters attributed users by source, uses source totals for shares, and resets", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /Slack/ })); + expect(screen.getByRole("button", { name: /Slack/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("status")).toHaveTextContent("Slack: 8 sessions"); + const users = within(screen.getByRole("list", { name: "Users for Slack" })); + expect(users.getByText("75%")).toBeInTheDocument(); + expect(users.queryByText("Unknown user")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "All sources" })); + expect(screen.getByRole("status")).toHaveTextContent("All sources: 10 sessions"); + await user.click(screen.getByRole("button", { name: /GitHub/ })); + await user.click(screen.getByRole("button", { name: /GitHub/ })); + expect(screen.getByRole("button", { name: "All sources" })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); + + it("keeps equal display names separate and shows their identity keys", () => { + render( + + ); + expect(screen.getAllByText("Alex")).toHaveLength(2); + expect(screen.getByText("user-a")).toBeInTheDocument(); + expect(screen.getByText("user-b")).toBeInTheDocument(); + }); + + it("shows loading and empty states", () => { + const { rerender } = render(); + expect(screen.getByRole("status")).toHaveTextContent("Loading session origins"); + rerender(); + expect(screen.getByText("No sessions found for this range and scope.")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("keeps accessible headings and controls local to each card instance", () => { + render( + <> + + + + ); + const cards = screen.getAllByRole("region", { name: "Session origins" }); + const ids: string[] = []; + for (const card of cards) { + const heading = within(card).getByRole("heading", { name: "Session origins" }); + expect(card).toHaveAttribute("aria-labelledby", heading.id); + const panel = within(card).getByRole("status").parentElement!; + for (const button of within(card).getAllByRole("button")) { + expect(button).toHaveAttribute("aria-controls", panel.id); + } + ids.push(heading.id, panel.id); + } + expect(ids.every(Boolean)).toBe(true); + expect(new Set(ids).size).toBe(4); + }); + + it("falls back to all sources when refreshed data no longer contains the selection", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await user.click(screen.getByRole("button", { name: /Slack/ })); + rerender( + entry.source === "github-bot")} + loading={false} + /> + ); + expect(screen.getByRole("status")).toHaveTextContent("All sources: 1 sessions"); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("All sources: 10 sessions"); + expect(screen.getByRole("button", { name: "All sources" })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); +}); diff --git a/packages/web/src/components/analytics/session-origins-card.tsx b/packages/web/src/components/analytics/session-origins-card.tsx new file mode 100644 index 0000000000..68e6563893 --- /dev/null +++ b/packages/web/src/components/analytics/session-origins-card.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useId, useState } from "react"; +import type { AnalyticsSessionOriginEntry } from "@open-inspect/shared/types/analytics"; +import type { SpawnSource } from "@open-inspect/shared/types/sessions"; +import { formatAnalyticsCount, formatAnalyticsRatio } from "@/lib/analytics"; +import { cn } from "@/lib/utils"; + +const SOURCES: Record = { + user: { label: "User / app", color: "bg-accent" }, + "slack-bot": { label: "Slack", color: "bg-info" }, + "github-bot": { label: "GitHub", color: "bg-foreground" }, + "linear-bot": { label: "Linear", color: "bg-secondary-foreground" }, + agent: { label: "Agent sub-sessions", color: "bg-warning" }, + automation: { label: "Automations", color: "bg-success" }, +}; + +export function AnalyticsSessionOriginsCard({ + entries, + loading, +}: { + entries?: AnalyticsSessionOriginEntry[]; + loading: boolean; +}) { + const headingId = useId(); + const usersId = useId(); + const [selectedSource, setSelectedSource] = useState(null); + const sourceCounts = new Map(); + for (const entry of entries ?? []) { + sourceCounts.set(entry.source, (sourceCounts.get(entry.source) ?? 0) + entry.sessions); + } + const sources = [...sourceCounts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + const total = sources.reduce((sum, [, count]) => sum + count, 0); + if (selectedSource && entries && !sourceCounts.has(selectedSource)) { + setSelectedSource(null); + } + const source = selectedSource && sourceCounts.has(selectedSource) ? selectedSource : null; + const selectedTotal = source ? sourceCounts.get(source)! : total; + const users = new Map(); + for (const entry of entries ?? []) { + if (source && entry.source !== source) continue; + const previous = users.get(entry.userKey); + users.set(entry.userKey, { + name: entry.displayName, + sessions: (previous?.sessions ?? 0) + entry.sessions, + }); + } + const sortedUsers = [...users].sort( + (a, b) => b[1].sessions - a[1].sessions || a[0].localeCompare(b[0]) + ); + const nameCounts = new Map(); + for (const user of users.values()) { + nameCounts.set(user.name, (nameCounts.get(user.name) ?? 0) + 1); + } + const sourceLabel = source ? SOURCES[source].label : "All sources"; + + return ( +
+
+

+ Session origins +

+

+ Where sessions start and who they are attributed to. Select a source to see its users. +

+
+ + {loading && !entries ? ( +
+ Loading session origins... +
+ ) : !total ? ( +
+ No sessions found for this range and scope. +
+ ) : ( +
+
+
+

+ By source +

+ +
+
    + {sources.map(([key, count]) => ( +
  • + +
  • + ))} +
+

+ {formatAnalyticsCount(total)} sessions. Percentages use the selected range and scope. +

+
+ +
+

+ Attributed users +

+

+ {sourceLabel}: {formatAnalyticsCount(selectedTotal)} sessions +

+
    + {sortedUsers.map(([key, user]) => ( +
  1. +
    +
    + {user.name} +
    + {key === "__unknown__" || nameCounts.get(user.name)! > 1 ? ( +
    + {key === "__unknown__" ? "No recorded user" : key} +
    + ) : null} +
    +
    +
    + {formatAnalyticsCount(user.sessions)} +
    +
    + {formatAnalyticsRatio(user.sessions / selectedTotal)} +
    +
    +
  2. + ))} +
+
+
+ )} + +
+ + How attribution works + +

+ Counts sessions created in the selected range and scope, including drafts, failures and + archived sessions. Private sessions are excluded. Sources describe creation, not later + messages or interactions. User / app includes user-authenticated creation and historical + sessions whose source defaulted to user; it does not mean browser-only usage. +

+

+ Users reflect recorded attribution: the requesting actor, the attributed user for an agent + sub-session, or the automation owner or manual triggerer. Integration actors are not + always people. Older sessions may use a separate legacy login or have no recorded user. +

+
+
+ ); +} diff --git a/packages/web/src/components/analytics/summary-cards.tsx b/packages/web/src/components/analytics/summary-cards.tsx index cfbe161603..12a4e24ea4 100644 --- a/packages/web/src/components/analytics/summary-cards.tsx +++ b/packages/web/src/components/analytics/summary-cards.tsx @@ -67,7 +67,7 @@ export function AnalyticsSummaryCards({ days, summary, loading }: SummaryCardsPr { }); expect(result.current).toMatchObject({ summary: snapshot.summary, + sessionOrigins: snapshot.sessionOrigins, timeseries: snapshot.timeseries, repoBreakdown: snapshot.breakdowns.repository, userBreakdown: snapshot.breakdowns.user, diff --git a/packages/web/src/hooks/use-analytics.ts b/packages/web/src/hooks/use-analytics.ts index ba538315a3..4f82797e83 100644 --- a/packages/web/src/hooks/use-analytics.ts +++ b/packages/web/src/hooks/use-analytics.ts @@ -16,6 +16,7 @@ export function useAnalyticsDashboard(days: AnalyticsDays, scope: AnalyticsScope return { summary: dashboard.data?.summary, + sessionOrigins: dashboard.data?.sessionOrigins, timeseries: dashboard.data?.timeseries, repoBreakdown: dashboard.data?.breakdowns.repository, userBreakdown: dashboard.data?.breakdowns.user, From 5abc1fbaf95d4c4bb0f558ddcaceb5140fb7b710 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 17:57:31 -0700 Subject: [PATCH 06/68] feat: enforce team boundaries for Slack channels (#2183) Closes [COL-208](https://linear.app/colemurray/issue/COL-208). ## Summary - Add provider-keyed channel binding storage, capability-protected team routes, Slack-only service lookup, and atomic `team.binding_added` / `team.binding_removed` audit events. - Validate bindings through the Slack bot's authenticated `conversations.info` endpoint. Refuse channels the bot has not joined and externally shared channels. - Resolve the channel's team before classification, scope repository/environment memory and KV caches, preserve scope through clarification interactions, and send `teamId` with session creation. - Preserve membership and repository-grant denial details. A forbidden follow-up reports lack of access without closing the thread for other users; unavailable sessions close rather than silently creating replacements. - Gate completion, tool progress, activity, automation completion, and `slack-notify` posts against the authoritative session row and current channel binding. Private and cross-team publication is refused, including workspace-visible sessions. - Add signed `channel` scope to completion reads. Queued publication also sends signed `purpose=slack-post`, applying the stricter outbound check at content/media reads without changing ordinary workspace-read semantics. Failed protected reads never publish queued error text or previously fetched content. - Persist thread closure independently of mapping/checkpoint writes, including early callbacks and automation threads without interactive mappings. - Enforce Slack automation/channel ownership at writes and candidate selection, while preserving workspace-owned automations in unbound channels. - Add the team's Channels tab, global Slack `unboundChannels` setting, and audit labels. The obsolete move warning and its provenance read contract have been removed. Update the changelog and both sandbox tool denial-guidance maps. ## Storage And Enforcement D1 migration `0083` already contains the binding and ownership schema; DO migration `56` is unchanged. No migrations, columns, or new deployment secrets are added. Bot endpoint authentication reuses the existing service callback signing credentials. Ordinary session reads preserve `off` / `shadow` / `on`: non-private team visibility is bypassed in `off`, audited in `shadow`, and enforced in `on`. Every non-read action on a team-owned session now requires current team membership in all modes, including for Owners and Administrators. Private restrictions remain active in every mode. Binding management and outbound publication checks enforce independently of that flag. The D1 session row is authoritative; the thread's stored `teamId` is only a cache. ## Checkpoint Report ### Commands And Results Validation ran sequentially with one Vitest worker. The control-plane and web suites below were rerun after rebasing onto `70b8ca4`. | Command | Final result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed | | `npm run typecheck` | All workspaces passed | | `npm run lint:fix` | Passed | | `npm run lint:sql-portability` | Clean; unchanged portability baseline | | `npm test -w @open-inspect/control-plane -- --maxWorkers=1 --silent` | 343 files, 5,774 tests passed | | `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1 --silent --reporter=dot` | 129 files, 1,704 tests passed; 1 existing skip | | `npm test -w @open-inspect/web -- --maxWorkers=1 --silent` | 261 files, 2,607 tests passed | | `npm test -w @open-inspect/slack-bot -- --maxWorkers=1 --silent` | 41 files, 593 tests passed | | `npm test -w @open-inspect/shared -- --maxWorkers=1 --silent` | 65 files, 1,115 tests passed | | `uv run --extra dev pytest tests/test_claude_tools.py tests/test_tool_installation.py -q` in `packages/sandbox-runtime` | 40 tests passed | | `uv run --extra dev ruff check src/sandbox_runtime/harness/claude_tools.py` | Passed | | `uv run --extra dev ruff format --check src/sandbox_runtime/harness/claude_tools.py` | Passed | | `git diff --name-only -z origin/main...HEAD \| xargs -0 npx prettier --check --ignore-unknown` | Passed | | `git diff origin/main...HEAD --check` | Passed | Targeted integration runs verified binding refusals, service audience restrictions, signed cross-team concealment before any runtime call, outbound refusal for every posting path, automation ownership, and originating-thread snapshot enrichment. Admission/catalog snapshots were regenerated and verified after the rebase, retaining the merged Teams follow-ups. Initial red tests and validation failures were resolved, not ignored. Selected verbatim diagnostics: ```text Error: Cannot find module './channel-scope' imported from /workspace/background-agents/packages/control-plane/src/authorization/channel-scope.test.ts Error: D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined' AssertionError: expected 201 to be 409 // Object.is equality AssertionError: expected 200 to be 404 // Object.is equality AssertionError: expected [ 'workspace', 'matching', 'other' ] to deeply equal [ 'workspace' ] TypeError: (intermediate value).hasCompatibleBindings is not a function AssertionError: expected 'user-or-service' to be 'service' // Object.is equality AssertionError: expected 1 to be less than 0 src/classifier/environments.test.ts(74,78): error TS2345: Argument of type 'string[]' is not assignable to parameter of type 'string'. src/classifier/repos.test.ts(201,78): error TS2345: Argument of type 'string[]' is not assignable to parameter of type 'string'. src/completion/delivery.test.ts(223,44): error TS2709: Cannot use namespace 'ExtractorModule' as a type. AssertionError: expected [Function] to throw error including 'Missing events pagination cursor' but got 'Invalid events response' shell tool terminated command after exceeding timeout 600000 ms. ``` The initial integration fixture omitted required `joinPolicy`; that fixture was corrected before confirming the behavioral red cases. Other fixes included adapting existing mocks and route counts, making lookup authentication service-only, updating the status-order assertion for the new async closure check, correctly modeling SWR reset revalidation, and removing a redundant pagination check already enforced by the response schema. The first full integration run exceeded the 10-minute shell timeout; both subsequent full runs passed with a 30-minute budget, taking about 12 minutes each. ### Verified Facts And Drift Initial inspection was on `main` at `19e7993`; the branch was subsequently rebased onto `70b8ca4`, preserving both Teams follow-ups. - `slack-bot/src/events/message-handler.ts:326-330` and `sessions/session-launcher.ts:194-203` still classified and launched without ownership scope. `sessions/control-plane-client.ts:74-81` discarded create denial details, and the follow-up path treated non-404 failures as transient. These are now scoped and denial-aware. - `slack-bot/src/events/dispatcher.ts:27-65` routes ordinary watched-channel messages through the separate actorless automation trigger path, rather than the interactive handler. Existing ingress is preserved; automation candidate selection now enforces channel ownership. - `control-plane/src/session/types.ts:36-67` shows that the callback service's local DO row lacks ownership/visibility. Gates use injected D1 index/binding reads, not that local row or participant records. - `slack-bot/src/target-clarification.ts:164-179` and `interactions/target-selection.ts:181` reloaded unscoped catalogs. Pending request scope now survives those paths, and suggestions/selection recheck the live binding. - Completion delivery uses a durable queue. A callback-time check alone could become stale, and ordinary service reads intentionally allow workspace-visible sessions. Publication-purpose reads close that gap while preserving the access resolver's existing contract. - `control-plane/src/session/snapshot-reader.ts:94-128` previously exposed no originating Slack channel. That enrichment was initially added for the move warning and has now been removed with ownership moves. - Existing callback signing uses `SERVICE_AUTH_SECRET_SLACK_BOT` and the bot's `SERVICE_AUTH_SECRET`; no additional Slack signing-secret configuration was needed. ### Deliberately Excluded - No migration, per-channel auto-response policy, or later-phase credential/token changes. - Repository-grant and environment-ownership catalog filters are not implemented here; they landed on main separately. This PR scopes Slack catalog reads with a signed `channel=slack:` coordinate, from which the control plane derives the channel's current team. - Slack posting and KV persistence are not an atomic exactly-once protocol. Closure markers prevent stale mapping writes from reopening threads, but existing distributed delivery/propagation races remain. A scope change after the final successful authority read cannot recall an already-in-flight Slack post. - Browser verification used fixture data and mocked APIs, not a live Slack workspace or production authentication. ## Follow-up Fixes - **Team-owned Slack automations at create (`0940757ef`).** `POST /automations` checked watched channels against workspace ownership instead of the automation's owner team. A team automation watching its own team's bound channel was refused with `channel_team_mismatch`. One watching an unbound channel was accepted, although candidate selection would never fire it. Creation now checks against the owner team, as update and candidate selection already did. New integration tests cover a bound channel (created, and selected as a candidate), an unbound channel (409) and another team's channel (409). The first two fail with the old check. - **Steering fixtures (`4408da48c`).** `scheduler-slack-team-steering.test.ts`, added on main with team-owned automations, seeded team automations in an unbound channel. This PR's candidate rule excludes that by design, which caused the 10 failures in control-plane integration 1/2. The fixtures now give each team automation the channel's team, and use an unbound channel for the workspace case; no runtime code changed. - **Closed threads reopen (`94253ebcb`).** A thread closed by a binding change or a private session used to stay closed until its marker expired, even after the binding or visibility was restored. Only threads that saw activity during the change were affected. A reply in a closed thread now re-checks the channel binding and the `slack-post` publication read. When both allow it, the bot clears the closure tombstone and notice marker and delivers the reply; otherwise the thread stays closed. The control plane still enforces scope on every prompt and post. Tests cover reopening, staying closed for another team's channel or denied publication, and a closure that lands during a reopen. | Command | Result | | --- | --- | | `npm run typecheck -w @open-inspect/control-plane` | Passed | | `npx prettier --check` and `npx eslint` on the changed files | Passed | | `npm test -w @open-inspect/control-plane -- --maxWorkers=2 --silent` | 365 files, 6,240 tests passed | | `npm run test:integration -w @open-inspect/control-plane` on the automation, scheduler and Slack files (final head) | 16 files, 282 tests passed | | `npm run typecheck -w @open-inspect/slack-bot` | Passed | | `npm test -w @open-inspect/slack-bot -- --silent` | 39 files, 621 tests passed | ## Visual Verification Real changed components and repository styles were verified in local component previews with fixture data. Binding/unbinding, primary/source selection, saving the unbound policy, move-warning conditions, and mobile fit were exercised. The corrected warning has 15.00:1 light-theme text contrast. All captures are viewport screenshots; URLs record local capture provenance and the preview server has been stopped. | Capture | Viewport | Source | Uploaded artifact ID | | --- | --- | --- | --- | | Channels desktop | 1440x1000 | `http://127.0.0.1:5173/channels` | `3ad98e81fe8e054b0930b52c95d41def` | | Channels mobile | 390x844 | `http://127.0.0.1:5173/channels` | `dcaf42617faf4aacb67e7a1f30869901` | | Slack settings desktop | 1440x1000 | `http://127.0.0.1:5173/slack` | `bd25ac2aa2443b82650960885228491f` | | Slack settings mobile | 390x844 | `http://127.0.0.1:5173/slack` | `4ebfe6cc73b82a7b3223d04891ffbc06` | | Historical move preview desktop (removed) | 1440x1000 | `http://127.0.0.1:5173/move` | `f1f6e4c6888eaa6ccf0620d8c3d20da5` | | Historical move preview mobile (removed) | 390x844 | `http://127.0.0.1:5173/move` | `1edfda15f09e9773408c2435eab18419` | --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/f9d5970ec4d60dc821b243d734ac1f4b)* ## Summary by CodeRabbit * **New Features** * Team administrators can manage Slack channel bindings from a Channels tab, assign primary or source channels, and review binding changes in the audit log. * Slack settings let administrators choose whether unbound channels create workspace-level sessions or reject requests. * Slack routing and automations respect channel team ownership, with clearer feedback when access or posting is denied. * **Bug Fixes** * Prevented notifications and completion content from being posted to private sessions or channels bound to another team. * Closed threads no longer trigger replacement sessions or receive later updates. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 12 + packages/control-plane/README.md | 15 + .../src/authorization/channel-scope.test.ts | 16 + .../src/authorization/channel-scope.ts | 9 + .../src/authorization/session-admission.ts | 17 +- .../src/authorization/slack-post-gate.test.ts | 47 + .../src/authorization/slack-post-gate.ts | 25 + .../src/db/integration-settings.ts | 1 + .../src/db/slack-channel-store.ts | 14 +- .../src/db/team-channel-bindings.test.ts | 75 ++ .../src/db/team-channel-bindings.ts | 149 +++ .../control-plane/src/http/request-context.ts | 2 + .../control-plane/src/router.policy.test.ts | 6 +- .../src/routes/automation-create.test.ts | 1 + .../src/routes/automation-crud.ts | 30 + .../src/routes/automation-slack-settings.ts | 22 +- packages/control-plane/src/routes/catalog.ts | 4 + .../src/routes/channel-bindings.ts | 44 + .../src/routes/environments-catalog.test.ts | 12 +- .../control-plane/src/routes/environments.ts | 14 +- packages/control-plane/src/routes/repos.ts | 11 +- .../src/routes/slack-notify.test.ts | 294 ++++- .../control-plane/src/routes/slack-notify.ts | 67 +- .../src/routes/team-channel-bindings.ts | 153 +++ .../src/routes/team-ownership.ts | 87 +- .../src/routing/route-admission.ts | 97 +- .../src/scheduler/scheduler.test.ts | 315 +++-- .../control-plane/src/scheduler/scheduler.ts | 85 +- .../callback-notification-service.test.ts | 567 +++++++-- .../session/callback-notification-service.ts | 204 +++- .../control-plane/src/session/components.ts | 8 + ...ono-route-catalog-conformance.test.ts.snap | 277 ++--- .../route-admission-matrix.test.ts.snap | 22 + .../automations-slack-route.test.ts | 126 +- .../integration/environments-catalog.test.ts | 120 ++ .../hono-route-catalog-conformance.test.ts | 2 +- .../route-admission-matrix.test.ts | 9 +- .../scheduler-slack-team-steering.test.ts | 17 +- .../test/integration/service-auth.test.ts | 2 +- .../integration/session-access-routes.test.ts | 473 +++++++- .../integration/slack-channel-store.test.ts | 41 + .../test/integration/slack-notify.test.ts | 135 ++- .../test/integration/slack-post-gates.test.ts | 186 +++ .../integration/team-channel-bindings.test.ts | 585 +++++++++ .../test/integration/team-grants.test.ts | 266 ++++- .../control-plane/test/smoke/run-smoke.mjs | 25 +- .../sandbox_runtime/harness/claude_tools.py | 1 + .../src/sandbox_runtime/tools/slack-notify.js | 2 + packages/shared/package.json | 4 + .../shared/src/completion/extractor.test.ts | 177 +++ packages/shared/src/completion/extractor.ts | 64 +- packages/shared/src/slack/client.test.ts | 61 +- packages/shared/src/slack/client.ts | 2 + packages/shared/src/slack/types.ts | 2 + .../shared/src/types/audit-events.test.ts | 2 + packages/shared/src/types/audit-events.ts | 2 + packages/shared/src/types/index.ts | 19 + .../shared/src/types/integrations.test.ts | 19 + packages/shared/src/types/integrations.ts | 6 + .../src/types/team-channel-bindings.test.ts | 40 + .../shared/src/types/team-channel-bindings.ts | 37 + packages/slack-bot/src/activity-status.ts | 5 + packages/slack-bot/src/app.ts | 2 + packages/slack-bot/src/attachments.test.ts | 53 +- packages/slack-bot/src/attachments.ts | 44 +- packages/slack-bot/src/callbacks.test.ts | 262 +++- packages/slack-bot/src/callbacks.ts | 82 +- packages/slack-bot/src/channel-bindings.ts | 48 + .../src/classifier/cached-resource.ts | 28 +- packages/slack-bot/src/classifier/catalog.ts | 16 +- .../slack-bot/src/classifier/control-plane.ts | 15 +- .../src/classifier/environments.test.ts | 108 +- .../slack-bot/src/classifier/environments.ts | 40 +- .../slack-bot/src/classifier/index.test.ts | 25 +- packages/slack-bot/src/classifier/index.ts | 2 +- .../slack-bot/src/classifier/repos.test.ts | 201 ++-- packages/slack-bot/src/classifier/repos.ts | 84 +- .../slack-bot/src/completion/consumer.test.ts | 28 +- packages/slack-bot/src/completion/consumer.ts | 8 +- .../slack-bot/src/completion/delivery.test.ts | 218 +++- packages/slack-bot/src/completion/delivery.ts | 88 +- .../src/completion/extractor.test.ts | 58 +- .../slack-bot/src/completion/extractor.ts | 5 +- .../src/completion/media-upload.test.ts | 261 +++- .../slack-bot/src/completion/media-upload.ts | 80 +- .../slack-bot/src/events/message-handler.ts | 341 +++--- packages/slack-bot/src/index.test.ts | 1051 +++++++++++------ packages/slack-bot/src/interaction-payload.ts | 1 + .../src/interactions/target-selection.test.ts | 58 +- .../src/interactions/target-selection.ts | 23 +- .../pending-request-store.test.ts | 27 +- .../pending-requests/pending-request-store.ts | 1 + .../slack-bot/src/routes/channel-info.test.ts | 118 ++ packages/slack-bot/src/routes/channel-info.ts | 47 + packages/slack-bot/src/routes/interactions.ts | 53 +- .../src/sessions/control-plane-client.test.ts | 123 +- .../src/sessions/control-plane-client.ts | 72 +- .../src/sessions/prompt-delivery.test.ts | 59 +- .../slack-bot/src/sessions/prompt-delivery.ts | 29 +- .../src/sessions/session-launcher.test.ts | 93 +- .../src/sessions/session-launcher.ts | 35 +- .../src/sessions/thread-session-store.test.ts | 267 +++-- .../src/sessions/thread-session-store.ts | 128 +- .../src/target-clarification.test.ts | 46 +- .../slack-bot/src/target-clarification.ts | 20 +- packages/slack-bot/src/types.ts | 5 + .../api/teams/[id]/channel-bindings/route.ts | 6 + .../slack/[channelId]/route.ts | 7 + .../api/teams/[id]/slack-channels/route.ts | 6 + .../settings/audit-log-settings.test.tsx | 2 + .../settings/audit-log-settings.tsx | 2 + .../slack-integration-settings.test.tsx | 142 ++- .../slack-integration-settings.tsx | 41 +- .../components/slack-notify-event.test.tsx | 10 + .../web/src/components/slack-notify-event.tsx | 4 + .../components/teams/team-channels.test.tsx | 323 +++++ .../src/components/teams/team-channels.tsx | 281 +++++ .../web/src/components/teams/team-page.tsx | 4 + .../src/components/teams/teams-pages.test.tsx | 19 +- packages/web/src/hooks/use-slack-channels.ts | 35 +- packages/web/src/lib/settings-proxy.test.ts | 33 + 121 files changed, 8531 insertions(+), 1869 deletions(-) create mode 100644 packages/control-plane/src/authorization/channel-scope.test.ts create mode 100644 packages/control-plane/src/authorization/channel-scope.ts create mode 100644 packages/control-plane/src/authorization/slack-post-gate.test.ts create mode 100644 packages/control-plane/src/authorization/slack-post-gate.ts create mode 100644 packages/control-plane/src/db/team-channel-bindings.test.ts create mode 100644 packages/control-plane/src/db/team-channel-bindings.ts create mode 100644 packages/control-plane/src/routes/channel-bindings.ts create mode 100644 packages/control-plane/src/routes/team-channel-bindings.ts create mode 100644 packages/control-plane/test/integration/slack-post-gates.test.ts create mode 100644 packages/control-plane/test/integration/team-channel-bindings.test.ts create mode 100644 packages/shared/src/types/team-channel-bindings.test.ts create mode 100644 packages/shared/src/types/team-channel-bindings.ts create mode 100644 packages/slack-bot/src/channel-bindings.ts create mode 100644 packages/slack-bot/src/routes/channel-info.test.ts create mode 100644 packages/slack-bot/src/routes/channel-info.ts create mode 100644 packages/web/src/app/api/teams/[id]/channel-bindings/route.ts create mode 100644 packages/web/src/app/api/teams/[id]/channel-bindings/slack/[channelId]/route.ts create mode 100644 packages/web/src/app/api/teams/[id]/slack-channels/route.ts create mode 100644 packages/web/src/components/teams/team-channels.test.tsx create mode 100644 packages/web/src/components/teams/team-channels.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 526c87632d..f6a170785e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,18 @@ session ownership. Team-only legacy OAuth refresh tokens do not enable managed a keys remain usable. Team-secret read and decryption errors abort environment builds rather than falling back to other secret scopes. +Team leads and administrators can manage primary and source Slack channel bindings in a team's +Channels tab. Settings > Integrations > Slack now controls whether unbound channels create +workspace-level sessions (the default) or reject requests until bound. Binding changes appear in the +workspace audit log. + +Slack-created sessions carry the channel's team and enforce the requesting user's membership. +Unavailable threads close instead of starting replacement sessions; a later reply reopens the thread +once the channel's binding and the session's visibility allow posting again. Session notifications +and the `slack-notify` tool refuse private sessions and destinations bound to another team, +including queued completion text and media. Slack-triggered automations run only in channels +matching their ownership. + **Team-owned environments.** The environment form offers team ownership, and team pages include an Environments tab. Team environments are visible to their members and administrators, and controls use server capabilities. Environment names are unique within each team. Sessions, including diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index 90a6e01389..72393ed27b 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -443,6 +443,21 @@ Existing sessions remain pinned to their stored authentication mode. > **Single-Tenant Only**: This control plane is designed for single-tenant deployment where all > users are trusted members of the same organization. +Slack-bot `POST /sessions/:id/prompt` and `POST /sessions/:id/attachments` requests require exactly +one `channel=slack:` query coordinate covered by the service signature. Before actor +enrollment or session writes, admission compares the live channel binding with the session's +persisted owning team in every `TEAMS_ENFORCEMENT` mode. Matching workspace/unbound scopes remain +valid; this check does not replace the user's collaboration or membership authorization. Scope +refusals return `slack_channel_scope_denied`, which closes the bot's thread mapping rather than +treating the refusal as a per-user denial or retrying with cached ownership. + +Slack channel catalogs use signed `GET /repos?channel=slack:` and +`GET /environments?channel=slack:` requests with the requesting user's actor assertion. +The control plane derives the team from its current binding, checks user access, and filters current +repository grants; a bot-supplied `teamId` cannot override that scope. Unbound channel scopes +include only workspace-owned environments, even for multi-team users and administrators. The bot +does not cache channel-catalog responses or fall back to workspace/team data on failed scoped reads. + Bulk archiving uses `POST /sessions/batch-archive` with an explicit selection: ```json diff --git a/packages/control-plane/src/authorization/channel-scope.test.ts b/packages/control-plane/src/authorization/channel-scope.test.ts new file mode 100644 index 0000000000..8c94897f10 --- /dev/null +++ b/packages/control-plane/src/authorization/channel-scope.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { parseChannelScope } from "./channel-scope"; + +describe("signed channel scope", () => { + it.each([ + ["slack:C1", { provider: "slack", externalId: "C1" }], + ["linear:team-1", { provider: "linear", externalId: "team-1" }], + ])("parses %s without confusing Slack workspace identity with team ownership", (value, scope) => { + expect(parseChannelScope(value)).toEqual(scope); + }); + + it.each(["", "C1", "slack:", "unknown:C1", "slack: C1", "slack:C1:other"])( + "rejects malformed or unsupported scope %s", + (value) => expect(parseChannelScope(value)).toBeNull() + ); +}); diff --git a/packages/control-plane/src/authorization/channel-scope.ts b/packages/control-plane/src/authorization/channel-scope.ts new file mode 100644 index 0000000000..d67bd89e26 --- /dev/null +++ b/packages/control-plane/src/authorization/channel-scope.ts @@ -0,0 +1,9 @@ +import { teamChannelBindingProviderSchema } from "@open-inspect/shared/types/team-channel-bindings"; + +/** The provider prefix prevents one bot from selecting another integration's scope. */ +export function parseChannelScope(value: string) { + const match = /^([^:]+):([^:\s]+)$/.exec(value); + if (!match) return null; + const provider = teamChannelBindingProviderSchema.safeParse(match[1]); + return provider.success ? { provider: provider.data, externalId: match[2] } : null; +} diff --git a/packages/control-plane/src/authorization/session-admission.ts b/packages/control-plane/src/authorization/session-admission.ts index b79e6a5fbc..7ad2ebc9d0 100644 --- a/packages/control-plane/src/authorization/session-admission.ts +++ b/packages/control-plane/src/authorization/session-admission.ts @@ -15,6 +15,7 @@ import { TeamMembershipStore } from "../db/team-memberships"; import type { RequestContext } from "../http/request-context"; import type { Env } from "../types"; import { auditPrivateSessionBreakGlass } from "./request-audit"; +import { slackPostGate } from "./slack-post-gate"; import { legacyPermissionForAction, parseTeamsEnforcementMode, @@ -33,7 +34,7 @@ export function viewerFromContext( const authorization = ctx.authorization; if (!authorization) { if (ctx.principal?.kind === "service" && !ctx.principal.actor) - return { kind: "service", teamId: null }; + return { kind: "service", teamId: ctx.serviceTeamId ?? null }; throw new Error("Missing request authorization"); } return { @@ -88,6 +89,20 @@ export async function evaluateSessionAdmission( const row = await new SessionIndexStore(ctx.db).get(sessionId); if (!row) return { kind: "not_found" }; + // Publication is narrower than workspace readability, including during rollback. + if ( + ctx.serviceReadPurpose === "slack-post" && + slackPostGate(row, ctx.serviceTeamId ? { teamId: ctx.serviceTeamId } : null) + ) { + const admission = { + row: { ...row, ownerUserId: row.userId ?? null, collaboratorIds: [] }, + viewer: viewerFromContext(ctx, new Map()), + }; + if (slot === "session") ctx.sessionAdmission = admission; + if (slot === "child") ctx.childSessionAdmission = admission; + return { kind: "not_found" }; + } + if (mode === "off" && !resolverDecides(mode, row, action)) { return { kind: "allowed", legacyPermission: legacyPermissionForAction(action) }; } diff --git a/packages/control-plane/src/authorization/slack-post-gate.test.ts b/packages/control-plane/src/authorization/slack-post-gate.test.ts new file mode 100644 index 0000000000..973aaef3fa --- /dev/null +++ b/packages/control-plane/src/authorization/slack-post-gate.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { slackPostGate, type SlackPostSession } from "./slack-post-gate"; + +describe("slackPostGate", () => { + it("refuses a missing session even for an unbound channel", () => { + expect(slackPostGate(null, null)).toBe("missing_session"); + }); + + it.each([null, { teamId: "team-a" }, { teamId: "team-b" }])( + "refuses private sessions regardless of binding %j", + (binding) => { + expect(slackPostGate({ ownerTeamId: "team-a", visibility: "private" }, binding)).toBe( + "private_session" + ); + } + ); + + it.each(["team", "workspace"] as const)( + "refuses cross-team publication for %s-visible sessions", + (visibility) => { + expect(slackPostGate({ ownerTeamId: "team-a", visibility }, { teamId: "team-b" })).toBe( + "channel_team_mismatch" + ); + } + ); + + it("refuses an ownerless session targeting a bound channel", () => { + expect( + slackPostGate({ ownerTeamId: null, visibility: "workspace" }, { teamId: "team-a" }) + ).toBe("channel_team_mismatch"); + }); + + it.each(["team", "workspace"] as const)( + "allows matching-team publication for %s-visible sessions", + (visibility) => { + expect(slackPostGate({ ownerTeamId: "team-a", visibility }, { teamId: "team-a" })).toBeNull(); + } + ); + + it.each([ + { ownerTeamId: "team-a", visibility: "team" }, + { ownerTeamId: "team-a", visibility: "workspace" }, + { ownerTeamId: null, visibility: "workspace" }, + ] satisfies SlackPostSession[])("allows an unbound channel for %j", (session) => { + expect(slackPostGate(session, null)).toBeNull(); + }); +}); diff --git a/packages/control-plane/src/authorization/slack-post-gate.ts b/packages/control-plane/src/authorization/slack-post-gate.ts new file mode 100644 index 0000000000..a0ac68db12 --- /dev/null +++ b/packages/control-plane/src/authorization/slack-post-gate.ts @@ -0,0 +1,25 @@ +import type { SessionEntry } from "../db/session-index"; + +export type SlackPostSession = Pick; +export interface SlackPostChannelBinding { + teamId: string; +} + +/** Reads current outbound scope, never the session DO's local mirror. */ +export interface SlackPostScope { + getSession(sessionId: string): Promise; + getChannelBinding(channelId: string): Promise; +} + +export type SlackPostDenial = "missing_session" | "private_session" | "channel_team_mismatch"; + +/** Workspace readability does not authorize publication into another team's channel. */ +export function slackPostGate( + session: SlackPostSession | null, + binding: SlackPostChannelBinding | null +): SlackPostDenial | null { + if (!session) return "missing_session"; + if (session.visibility === "private") return "private_session"; + if (binding && binding.teamId !== session.ownerTeamId) return "channel_team_mismatch"; + return null; +} diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts index 02179023ef..160d620564 100644 --- a/packages/control-plane/src/db/integration-settings.ts +++ b/packages/control-plane/src/db/integration-settings.ts @@ -587,6 +587,7 @@ export class IntegrationSettingsStore { "agentNotificationsEnabled", "model", "mentionsPolicy", + "unboundChannels", "routingRules", "sessionInstructions", ]) diff --git a/packages/control-plane/src/db/slack-channel-store.ts b/packages/control-plane/src/db/slack-channel-store.ts index 7474313f6f..5400ddf44b 100644 --- a/packages/control-plane/src/db/slack-channel-store.ts +++ b/packages/control-plane/src/db/slack-channel-store.ts @@ -16,6 +16,7 @@ import { withValidatedOwnerTeam, type AutomationRow } from "./automation-store"; import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { TeamChannelBindingStore } from "./team-channel-bindings"; export class SlackChannelStore { constructor(private readonly db: SqlDatabase) {} @@ -26,14 +27,25 @@ export class SlackChannelStore { .prepare( `SELECT a.* FROM automations a JOIN automation_slack_channels c ON c.automation_id = a.id + LEFT JOIN team_channel_bindings b ON b.provider = 'slack' AND b.external_id = c.channel_id WHERE c.channel_id = ? AND a.enabled = 1 AND a.deleted_at IS NULL - AND a.trigger_type = 'slack_event'` + AND a.trigger_type = 'slack_event' + AND (a.owner_team_id = b.team_id OR (a.owner_team_id IS NULL AND b.team_id IS NULL))` ) .bind(channelId) .all(); return (result.results || []).map(withValidatedOwnerTeam); } + /** Workspace automations retain unbound channels; team automations require matching bindings. */ + async hasCompatibleBindings(channelIds: string[], ownerTeamId: string | null): Promise { + const bindings = new TeamChannelBindingStore(this.db); + for (const channelId of channelIds) { + if (((await bindings.get("slack", channelId))?.teamId ?? null) !== ownerTeamId) return false; + } + return true; + } + /** Distinct channel IDs watched by any enabled slack_event automation. */ async getWatchedSlackChannels(): Promise { const result = await this.db diff --git a/packages/control-plane/src/db/team-channel-bindings.test.ts b/packages/control-plane/src/db/team-channel-bindings.test.ts new file mode 100644 index 0000000000..59bfbbe769 --- /dev/null +++ b/packages/control-plane/src/db/team-channel-bindings.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { TeamChannelBindingConflictError, TeamChannelBindingStore } from "./team-channel-bindings"; + +const binding = { + provider: "slack", + externalId: "C123", + teamId: "team_engineering", + kind: "source", +} as const; +const actor = { requestId: "binding-request", actorUserId: "lead" }; + +function database(rows: unknown[] = []) { + const prepare = vi.fn((_sql: string): SqlStatement => { + const statement: SqlStatement = { + bind: vi.fn(() => statement), + first: async () => (rows[0] as T | undefined) ?? null, + all: async () => ({ results: rows as T[], meta: { changes: 0 } }), + run: vi.fn().mockRejectedValue(new Error("Mutation must use an atomic batch")), + }; + return statement; + }); + const batch = vi.fn(async (statements: SqlStatement[]) => + statements.map(() => ({ results: [], meta: { changes: 1 } })) + ); + const db: SqlDatabase = { prepare, batch }; + return { db, prepare, batch }; +} + +describe("TeamChannelBindingStore", () => { + it("keys lookup by provider and external ID and validates stored rows", async () => { + const { db, prepare } = database([binding]); + expect(await new TeamChannelBindingStore(db).get("slack", "C123")).toEqual(binding); + expect(prepare.mock.results[0]!.value.bind).toHaveBeenCalledWith("slack", "C123"); + expect(await new TeamChannelBindingStore(database().db).get("linear", "C123")).toBeNull(); + await expect( + new TeamChannelBindingStore(database([{ ...binding, kind: "unknown" }]).db).get( + "slack", + "C123" + ) + ).rejects.toThrow(); + }); + + it("validates lists and scopes reads to the requested team ID", async () => { + const { db, prepare } = database([{ ...binding, created_at: 1 }]); + await expect(new TeamChannelBindingStore(db).listByTeam(binding.teamId)).rejects.toThrow(); + expect(prepare.mock.results[0]!.value.bind).toHaveBeenCalledWith(binding.teamId); + expect( + await new TeamChannelBindingStore(database([binding]).db).listByTeam(binding.teamId) + ).toEqual([binding]); + }); + + it("batches predicate-gated audit and mutation SQL without engine-specific functions", async () => { + const { db, prepare, batch } = database(); + expect(await new TeamChannelBindingStore(db).put(binding, actor)).toEqual(binding); + expect(batch).toHaveBeenCalledOnce(); + const sql = prepare.mock.calls.map(([query]) => query).join("\n"); + expect(sql).toContain("NOT EXISTS"); + expect(sql).toContain("ON CONFLICT (provider, external_id)"); + expect(sql).not.toMatch(/changes\(|json_|INSERT OR IGNORE/i); + expect(batch.mock.calls[0]![0]).toEqual(prepare.mock.results.map(({ value }) => value)); + }); + + it("maps uniqueness failures to conflicts but preserves storage failures", async () => { + const { db, batch } = database(); + batch.mockRejectedValueOnce( + new Error("UNIQUE constraint failed: team_channel_bindings.team_id") + ); + const store = new TeamChannelBindingStore(db); + await expect(store.put(binding, actor)).rejects.toBeInstanceOf(TeamChannelBindingConflictError); + const failure = new Error("storage unavailable"); + batch.mockRejectedValueOnce(failure); + await expect(store.put(binding, actor)).rejects.toBe(failure); + }); +}); diff --git a/packages/control-plane/src/db/team-channel-bindings.ts b/packages/control-plane/src/db/team-channel-bindings.ts new file mode 100644 index 0000000000..34af3711dd --- /dev/null +++ b/packages/control-plane/src/db/team-channel-bindings.ts @@ -0,0 +1,149 @@ +import { + teamChannelBindingSchema, + type TeamChannelBinding, + type TeamChannelBindingProvider, +} from "@open-inspect/shared/types/team-channel-bindings"; +import { isUniqueConstraintError } from "./errors"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +export interface TeamChannelBindingActor { + requestId: string; + actorUserId: string; +} + +export class TeamChannelBindingConflictError extends Error { + constructor() { + super("Channel binding conflicts with an existing binding"); + this.name = "TeamChannelBindingConflictError"; + } +} + +const BINDING_COLUMNS = 'provider, external_id AS "externalId", team_id AS "teamId", kind'; + +export class TeamChannelBindingStore { + constructor(private readonly db: SqlDatabase) {} + + async get( + provider: TeamChannelBindingProvider, + externalId: string + ): Promise { + const row = await this.db + .prepare( + `SELECT ${BINDING_COLUMNS} FROM team_channel_bindings WHERE provider = ? AND external_id = ?` + ) + .bind(provider, externalId) + .first(); + return row ? teamChannelBindingSchema.parse(row) : null; + } + + async listByTeam(teamId: string): Promise { + const rows = await this.db + .prepare( + `SELECT ${BINDING_COLUMNS} FROM team_channel_bindings + WHERE team_id = ? ORDER BY provider, external_id` + ) + .bind(teamId) + .all(); + return rows.results.map((row) => teamChannelBindingSchema.parse(row)); + } + + async put( + binding: TeamChannelBinding, + actor: TeamChannelBindingActor + ): Promise { + binding = teamChannelBindingSchema.parse(binding); + const { provider, externalId, teamId, kind } = binding; + try { + const [, result] = await this.db.batch([ + this.bindAudit("team.binding_added", binding, actor), + this.db + .prepare( + `INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (provider, external_id) DO UPDATE SET kind = excluded.kind + WHERE team_channel_bindings.team_id = excluded.team_id + AND team_channel_bindings.kind <> excluded.kind` + ) + .bind(provider, externalId, teamId, kind, Date.now()), + ]); + if (result.meta.changes === 0) { + const existing = await this.get(provider, externalId); + if (existing?.teamId !== teamId || existing.kind !== kind) { + throw new TeamChannelBindingConflictError(); + } + } + } catch (cause) { + if (isUniqueConstraintError(cause)) throw new TeamChannelBindingConflictError(); + throw cause; + } + return binding; + } + + async remove( + teamId: string, + provider: TeamChannelBindingProvider, + externalId: string, + actor: TeamChannelBindingActor + ): Promise { + const [, result] = await this.db.batch([ + this.bindAudit( + "team.binding_removed", + { teamId, provider, externalId, kind: "source" }, + actor + ), + this.db + .prepare( + "DELETE FROM team_channel_bindings WHERE team_id = ? AND provider = ? AND external_id = ?" + ) + .bind(teamId, provider, externalId), + ]); + return result.meta.changes > 0; + } + + private bindAudit( + action: "team.binding_added" | "team.binding_removed", + binding: TeamChannelBinding, + actor: TeamChannelBindingActor + ): SqlStatement { + const { teamId, provider, externalId, kind } = binding; + const removing = action === "team.binding_removed"; + const metadata = (before: object) => + JSON.stringify({ before, requested: {}, after: removing ? {} : binding }); + + // Read the previous kind inside the same batch snapshot as the mutation. + // Predicate-gated audit writes avoid SQLite-specific changes() and omit no-ops. + return this.db + .prepare( + `INSERT INTO authorization_audit_events + (id, occurred_at, request_id, principal_kind, actor_user_id_snapshot, + action, resource_type, resource_id, team_id, reason_code, operation_result, metadata_json) + SELECT ?, ?, ?, 'user', ?, ?, 'team', ?, ?, ?, 'applied', + CASE (SELECT kind FROM team_channel_bindings + WHERE team_id = ? AND provider = ? AND external_id = ?) + WHEN 'primary' THEN ? WHEN 'source' THEN ? ELSE ? END + WHERE ${ + removing + ? "EXISTS (SELECT 1 FROM team_channel_bindings WHERE team_id = ? AND provider = ? AND external_id = ?)" + : `NOT EXISTS (SELECT 1 FROM team_channel_bindings + WHERE provider = ? AND external_id = ? AND (team_id <> ? OR kind = ?))` + }` + ) + .bind( + crypto.randomUUID(), + Date.now(), + actor.requestId, + actor.actorUserId, + action, + teamId, + teamId, + action, + teamId, + provider, + externalId, + metadata({ ...binding, kind: "primary" }), + metadata({ ...binding, kind: "source" }), + metadata({}), + ...(removing ? [teamId, provider, externalId] : [provider, externalId, teamId, kind]) + ); + } +} diff --git a/packages/control-plane/src/http/request-context.ts b/packages/control-plane/src/http/request-context.ts index d3e3e9528c..09f5fec7bd 100644 --- a/packages/control-plane/src/http/request-context.ts +++ b/packages/control-plane/src/http/request-context.ts @@ -38,6 +38,8 @@ export type RequestContext = AuthenticationRequestServices & { sessionAdmission?: { row: SessionEntry & SessionAccessRow; viewer: SessionViewer }; childSessionAdmission?: { row: SessionEntry & SessionAccessRow; viewer: SessionViewer }; sessionMemberships?: ReadonlyMap; + serviceTeamId?: string | null; + serviceReadPurpose?: "slack-post"; teamsEnforcementMode?: TeamsEnforcementMode; shadowSessionDenial?: string; shadowBatchDenials?: { sessionId: string; reason: string }[]; diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index 157dd45a2c..de14571cc6 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -24,11 +24,11 @@ describe("route policy table", () => { }); it("publishes the complete canonical route catalog", () => { - expect(routes).toHaveLength(207); + expect(routes).toHaveLength(212); const paths = routes.map((route) => route.path); - expect(new Set(paths).size).toBe(156); - expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(207); + expect(new Set(paths).size).toBe(160); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(212); }); it("gates run analytics with analytics.read", () => { diff --git a/packages/control-plane/src/routes/automation-create.test.ts b/packages/control-plane/src/routes/automation-create.test.ts index 40d3e9d3db..4122574772 100644 --- a/packages/control-plane/src/routes/automation-create.test.ts +++ b/packages/control-plane/src/routes/automation-create.test.ts @@ -102,6 +102,7 @@ vi.mock("../auth/crypto", () => ({ })); const mockSlackChannelStore = { + hasCompatibleBindings: vi.fn(async () => true), bindChannelStatements: vi.fn(), getWatchedSlackChannels: vi.fn(), }; diff --git a/packages/control-plane/src/routes/automation-crud.ts b/packages/control-plane/src/routes/automation-crud.ts index 73e57d21d2..da4932ff91 100644 --- a/packages/control-plane/src/routes/automation-crud.ts +++ b/packages/control-plane/src/routes/automation-crud.ts @@ -239,6 +239,20 @@ async function handleCreateAutomation( ...body.triggerConfig!, conditions: normalizeSlackChannelConditions(body.triggerConfig!.conditions), }; + if ( + !(await new SlackChannelStore(ctx.db).hasCompatibleBindings( + extractSlackChannels(body.triggerConfig), + ownerTeamId + )) + ) { + return json( + { + error: "Slack channels must belong to the automation's team", + code: "channel_team_mismatch", + }, + 409 + ); + } } // Validate harness and model @@ -677,6 +691,22 @@ async function handleUpdateAutomation( triggerConfigToValidate = body.triggerConfig; } + if ( + existingTriggerType === "slack_event" && + !(await new SlackChannelStore(ctx.db).hasCompatibleBindings( + extractSlackChannels(body.triggerConfig ?? existingTriggerConfig ?? undefined), + existing.owner_team_id + )) + ) { + return json( + { + error: "Slack channels must belong to the automation's team", + code: "channel_team_mismatch", + }, + 409 + ); + } + if (triggerConfigToValidate) { let previousConfig: TriggerConfig | undefined; if (existingTriggerType === "github_event" && existingTriggerConfig !== null) { diff --git a/packages/control-plane/src/routes/automation-slack-settings.ts b/packages/control-plane/src/routes/automation-slack-settings.ts index d03bf43be5..7b85e1b1b3 100644 --- a/packages/control-plane/src/routes/automation-slack-settings.ts +++ b/packages/control-plane/src/routes/automation-slack-settings.ts @@ -4,6 +4,7 @@ import { listChannels, type ControlPlaneSlackChannelsResponse } from "@open-inspect/shared/slack"; import { SlackChannelStore } from "../db/slack-channel-store"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; import { Hono } from "hono"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; @@ -45,7 +46,7 @@ async function handleGetWatchedSlackChannels( * GET /integration-settings/slack/channels * * Lists the workspace's channels (public + private the bot can see) so the - * automation form can offer a channel picker instead of a raw channel ID. Sourced + * automation and team binding forms can offer a channel picker. Sourced * live from Slack via `conversations.list` using the bot token. * * Returns `{ channels }` on success, or `{ channels: [], error }` when the token @@ -53,11 +54,11 @@ async function handleGetWatchedSlackChannels( * scope) — the form then degrades to manual channel-ID entry. Internal-auth gated * by the router (non-public route). */ -async function handleGetSlackChannels( +export async function handleGetSlackChannels( request: Request, env: Env, _params: object, - _ctx: RequestContext + ctx: RequestContext ): Promise { if (!env.SLACK_BOT_TOKEN) { return json({ @@ -70,7 +71,20 @@ async function handleGetSlackChannels( logger.warn("slack.channels.list_failed", { slack_error: result.error }); return json({ channels: [], error: result.error } satisfies ControlPlaneSlackChannelsResponse); } - return json({ channels: result.channels } satisfies ControlPlaneSlackChannelsResponse); + let channels = result.channels; + if (ctx.teamAdmission && !ctx.authorization?.permissions.includes("automations.read")) { + // A team capability must not expose unrelated private workspace channels. + const bindings = await new TeamChannelBindingStore(ctx.db).listByTeam( + ctx.teamAdmission.team.id + ); + const boundIds = new Set( + bindings + .filter((binding) => binding.provider === "slack") + .map((binding) => binding.externalId) + ); + channels = channels.filter((channel) => !channel.isPrivate || boundIds.has(channel.id)); + } + return json({ channels } satisfies ControlPlaneSlackChannelsResponse); } export const automationSlackSettingsRoutes = new Hono(); diff --git a/packages/control-plane/src/routes/catalog.ts b/packages/control-plane/src/routes/catalog.ts index 613d9d0e82..c0c1fbe25b 100644 --- a/packages/control-plane/src/routes/catalog.ts +++ b/packages/control-plane/src/routes/catalog.ts @@ -12,6 +12,7 @@ import { auditEventRoutes } from "./audit-events"; import { autofixRoutes } from "./autofix"; import { automationRoutes } from "./automations"; import { browserAuthRoutes } from "./browser-auth"; +import { channelBindingRoutes } from "./channel-bindings"; import { commitSigningRoutes } from "./commit-signing"; import { environmentSecretsRoutes } from "./environment-secrets"; import { environmentRoutes } from "./environments"; @@ -32,6 +33,7 @@ import { slackNotifyRoutes } from "./slack-notify"; import { signInProviderRoutes } from "./sign-in-providers"; import { skillRoutes } from "./skills"; import { teamRoutes } from "./teams"; +import { teamChannelBindingRoutes } from "./team-channel-bindings"; import { teamSecretsRoutes } from "./team-secrets"; import { teamSettingsRoutes } from "./settings-teams"; @@ -42,8 +44,10 @@ export const catalog: readonly RouteModule[] = [ browserAuthRoutes, signInProviderRoutes, + teamChannelBindingRoutes, teamRoutes, teamSettingsRoutes, + channelBindingRoutes, // Session management, then the agent-initiated Slack notification sessionRoutes, diff --git a/packages/control-plane/src/routes/channel-bindings.ts b/packages/control-plane/src/routes/channel-bindings.ts new file mode 100644 index 0000000000..739acfc986 --- /dev/null +++ b/packages/control-plane/src/routes/channel-bindings.ts @@ -0,0 +1,44 @@ +import { Hono } from "hono"; +import { DEFAULT_SLACK_UNBOUND_CHANNELS } from "@open-inspect/shared/types/integrations"; +import { channelBindingResponseSchema } from "@open-inspect/shared/types/team-channel-bindings"; +import { IntegrationSettingsStore } from "../db/integration-settings"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; +import type { RequestContext } from "../http/request-context"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import { error, json, serviceAuthorized } from "./shared"; + +async function getBinding( + _request: Request, + _env: Env, + params: { provider: string; externalId: string }, + ctx: RequestContext +) { + if (params.provider !== "slack") return error("Unsupported channel binding provider", 400); + const binding = await new TeamChannelBindingStore(ctx.db).get("slack", params.externalId); + if (binding) { + return json(channelBindingResponseSchema.parse({ teamId: binding.teamId, kind: binding.kind })); + } + // Unbound DMs are personal conversations, not team routing destinations. + if (/^D[A-Z0-9]+$/.test(params.externalId)) { + return json(channelBindingResponseSchema.parse({ teamId: null })); + } + const settings = await new IntegrationSettingsStore(ctx.db).getGlobal("slack"); + if ((settings?.defaults?.unboundChannels ?? DEFAULT_SLACK_UNBOUND_CHANNELS) === "reject") { + return json({ error: "Channel is not bound", code: "channel_unbound" }, 404); + } + return json(channelBindingResponseSchema.parse({ teamId: null })); +} + +export const channelBindingRoutes = new Hono(); +channelBindingRoutes.get( + "/channel-bindings/:provider/:externalId", + admit({ + authentication: { kind: "service" }, + supportedScmProviders: "all", + cacheControl: "private, no-store", + authorization: serviceAuthorized("slack-bot"), + }), + (c) => dispatch(c, getBinding) +); diff --git a/packages/control-plane/src/routes/environments-catalog.test.ts b/packages/control-plane/src/routes/environments-catalog.test.ts index e3cf939582..c5a4a181e8 100644 --- a/packages/control-plane/src/routes/environments-catalog.test.ts +++ b/packages/control-plane/src/routes/environments-catalog.test.ts @@ -3,6 +3,7 @@ import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; import type * as AuthenticateModule from "../auth/authenticate"; import * as requestAudit from "../authorization/request-audit"; import { AuthorizationStore } from "../db/authorization-store"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; import { EnvironmentStore, toEnvironment, @@ -321,6 +322,12 @@ describe("environment catalog team scope", () => { ); it("scopes an acting service using its canonical actor's actual membership", async () => { + vi.spyOn(TeamChannelBindingStore.prototype, "get").mockResolvedValue({ + provider: "slack", + externalId: "C-CATALOG", + teamId: TEAM_ID, + kind: "source", + }); mocks.authenticate.mockImplementation(async (request: Request) => ({ principal: { kind: "service", @@ -335,7 +342,10 @@ describe("environment catalog team scope", () => { request, })); - expect(await (await list()).json()).toEqual({ environments: [fullCatalog[0]], total: 1 }); + expect(await (await list("?channel=slack:C-CATALOG")).json()).toEqual({ + environments: [fullCatalog[0]], + total: 1, + }); // Both the team gate and the read-filter viewer resolve the canonical actor's memberships. const lookups = vi.mocked(TeamMembershipStore.prototype.listForUser).mock.calls; expect(lookups.length).toBeGreaterThan(0); diff --git a/packages/control-plane/src/routes/environments.ts b/packages/control-plane/src/routes/environments.ts index 260b0bf9e9..d884d63a78 100644 --- a/packages/control-plane/src/routes/environments.ts +++ b/packages/control-plane/src/routes/environments.ts @@ -51,7 +51,7 @@ import { import type { Env } from "../types"; import { authorizeSessionTarget } from "./session-target-authorization"; import { - admitTeamCatalog, + resolveCatalogScope, resolveCreationOwnerTeam, type TeamRepositoryGrants, } from "./team-ownership"; @@ -198,11 +198,9 @@ async function handleListEnvironments( ): Promise { const query = parseQuery(request, listQuerySchema); if (query instanceof Response) return query; - const catalogTeamId = query.teamId || null; - const catalogGrants = catalogTeamId - ? await admitTeamCatalog(request, ctx, catalogTeamId, "/environments") - : null; - if (catalogGrants instanceof Response) return catalogGrants; + const scope = await resolveCatalogScope(request, ctx, query.teamId || null, "/environments"); + if (scope instanceof Response) return scope; + const catalogGrants = scope?.grants; const store = new EnvironmentStore(ctx.db); const viewer = await resourceViewer(ctx); @@ -211,9 +209,9 @@ async function handleListEnvironments( ); const readable = (row: EnvironmentRow) => checkEnvironmentAccess(viewer, { ownerTeamId: row.owner_team_id }, "read").allowed; - // A team's sessions cannot launch with environments other teams own. + // Explicit workspace scopes exclude all teams; team scopes may also use workspace environments. const launchableByCatalogTeam = (row: EnvironmentRow) => - !catalogTeamId || row.owner_team_id === null || row.owner_team_id === catalogTeamId; + scope === null || row.owner_team_id === null || row.owner_team_id === scope.teamId; let environments = rows.filter((row) => readable(row) && launchableByCatalogTeam(row)); const repositoriesById = await store.getRepositoriesForEnvironmentIds( environments.map((row) => row.id) diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index 086f844ccf..7ca417f2dc 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -8,7 +8,7 @@ import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { repositoryParams } from "./repository-params"; import { RepoMetadataStore } from "../db/repo-metadata"; import type { Env } from "../types"; -import { admitTeamCatalog, type TeamRepositoryGrants } from "./team-ownership"; +import { resolveCatalogScope } from "./team-ownership"; import type { SqlDatabase } from "../db/sql-database"; import { repoMetadataSchema, @@ -143,12 +143,9 @@ async function handleListRepos( ctx: RequestContext ): Promise { const teamId = new URL(request.url).searchParams.get("teamId"); - let grants: TeamRepositoryGrants | undefined; - if (teamId !== null) { - const admitted = await admitTeamCatalog(request, ctx, teamId, "/repos"); - if (admitted instanceof Response) return admitted; - grants = admitted; - } + const scope = await resolveCatalogScope(request, ctx, teamId, "/repos"); + if (scope instanceof Response) return scope; + const grants = scope?.grants; const filterRepos = (repos: EnrichedRepository[]) => { if (!grants || grants.some((grant) => grant.grant_kind === "installation")) return repos; const ids = new Set(grants.map((grant) => grant.repo_external_id)); diff --git a/packages/control-plane/src/routes/slack-notify.test.ts b/packages/control-plane/src/routes/slack-notify.test.ts index 798394e643..5911e1620e 100644 --- a/packages/control-plane/src/routes/slack-notify.test.ts +++ b/packages/control-plane/src/routes/slack-notify.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionStatus } from "@open-inspect/shared/types/sessions"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; import { SECTION_TEXT_MAX_CHARS } from "@open-inspect/shared/slack"; -import { handleSlackNotify } from "./slack-notify"; +import type * as SlackNotify from "./slack-notify"; import type { RequestContext } from "./shared"; import type { SqlDatabase } from "../db/sql-database"; import type { Env } from "../types"; @@ -10,6 +11,19 @@ import { fakeSessionRuntimeDispatch, TEST_BACKGROUND_TASK_CONTEXT } from "../rou const sessionStoreMock = { get: vi.fn(), }; +const channelBindingStoreMock = { get: vi.fn() }; +const listChannelsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@open-inspect/shared/slack", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, listChannels: listChannelsMock }; +}); + +vi.mock("../db/team-channel-bindings", () => ({ + TeamChannelBindingStore: vi.fn().mockImplementation(function () { + return channelBindingStoreMock; + }), +})); const integrationStoreMock = { getResolvedConfig: vi.fn(), @@ -42,6 +56,7 @@ const sessionFetchMock = vi.fn(); const PATH = "/sessions/sess-1/slack-notify"; const PATTERN = /^\/sessions\/(?[^/]+)\/slack-notify$/; +let handleSlackNotify: typeof SlackNotify.handleSlackNotify; function createCtx(): RequestContext { return { @@ -93,6 +108,8 @@ function seedActiveSession(opts?: { status?: SessionStatus; repoOwner?: string | null; repoName?: string | null; + ownerTeamId?: string | null; + visibility?: SessionVisibility; }) { sessionStoreMock.get.mockResolvedValue({ id: "sess-1", @@ -103,6 +120,8 @@ function seedActiveSession(opts?: { reasoningEffort: null, baseBranch: null, status: opts?.status ?? "active", + ownerTeamId: opts?.ownerTeamId ?? "team-a", + visibility: opts?.visibility ?? "workspace", parentSessionId: opts?.parentSessionId ?? null, spawnSource: opts?.spawnSource ?? "user", spawnDepth: 0, @@ -125,8 +144,19 @@ let consoleLogSpy: ReturnType; let consoleWarnSpy: ReturnType; let consoleErrorSpy: ReturnType; -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + ({ handleSlackNotify } = await import("./slack-notify")); vi.clearAllMocks(); + channelBindingStoreMock.get.mockResolvedValue(null); + listChannelsMock.mockResolvedValue({ + ok: true, + channels: [ + { id: "C1", name: "ops", isMember: true, isPrivate: false }, + { id: "C2", name: "nope", isMember: true, isPrivate: false }, + { id: "C3", name: "archive", isMember: true, isPrivate: false }, + ], + }); sessionFetchMock.mockResolvedValue(new Response("{}", { status: 200 })); vi.stubGlobal("fetch", fetchMock); consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -135,6 +165,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); @@ -161,18 +192,178 @@ function lastLogPayload( } describe("handleSlackNotify", () => { - it("happy path posts no events to the DO — the agent's tool_call is the source of truth", async () => { + describe("channel-name cache", () => { + beforeEach(() => { + seedActiveSession(); + integrationStoreMock.getResolvedConfig.mockResolvedValue({ + settings: { agentNotificationsEnabled: true }, + }); + fetchMock.mockImplementation(async () => + Response.json({ + ok: true, + channel: "C1", + ts: "1.2", + permalink: "https://x.slack.com/p", + }) + ); + }); + + it("populates names from the listing for subsequent channel lookups", async () => { + expect((await callHandler({ channel: "#ops", text: "Done" })).status).toBe(200); + expect((await callHandler({ channel: "#archive", text: "Done" })).status).toBe(200); + expect(listChannelsMock).toHaveBeenCalledOnce(); + expect(channelBindingStoreMock.get).toHaveBeenLastCalledWith("slack", "C3"); + }); + + it("does not reuse another bot token's channel IDs", async () => { + listChannelsMock.mockImplementation(async (token: string) => ({ + ok: true, + channels: [{ id: token === "xoxb-test" ? "C1" : "CSECOND", name: "ops" }], + })); + expect((await callHandler({ channel: "#ops", text: "Done" })).status).toBe(200); + expect( + (await callHandler({ channel: "ops", text: "Done" }, { SLACK_BOT_TOKEN: "xoxb-second" })) + .status + ).toBe(200); + expect(listChannelsMock).toHaveBeenCalledTimes(2); + expect(channelBindingStoreMock.get).toHaveBeenLastCalledWith("slack", "CSECOND"); + }); + + it("refreshes expired entries rather than extending their TTL on a hit", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const nowMs = Date.parse("2026-10-01T00:00:00Z"); + vi.setSystemTime(nowMs); + expect((await callHandler({ channel: "#OPS", text: "Done" })).status).toBe(200); + vi.setSystemTime(nowMs + 59_999); + expect((await callHandler({ channel: "ops", text: "Done" })).status).toBe(200); + expect(listChannelsMock).toHaveBeenCalledOnce(); + expect(channelBindingStoreMock.get).toHaveBeenLastCalledWith("slack", "C1"); + listChannelsMock.mockResolvedValue({ ok: true, channels: [{ id: "CNEW", name: "ops" }] }); + vi.setSystemTime(nowMs + 60_000); + expect((await callHandler({ channel: "#ops", text: "Done" })).status).toBe(200); + expect(listChannelsMock).toHaveBeenCalledTimes(2); + expect(channelBindingStoreMock.get).toHaveBeenLastCalledWith("slack", "CNEW"); + expect(sessionStoreMock.get).toHaveBeenCalledTimes(6); + expect(channelBindingStoreMock.get).toHaveBeenCalledTimes(3); + }); + + it("bounds the cache while retaining the requested name from a large listing", async () => { + listChannelsMock.mockResolvedValue({ + ok: true, + channels: Array.from({ length: 1001 }, (_, index) => ({ + id: `C${index}`, + name: `channel-${index}`, + })), + }); + expect((await callHandler({ channel: "channel-0", text: "Done" })).status).toBe(200); + expect((await callHandler({ channel: "channel-0", text: "Done" })).status).toBe(200); + expect(listChannelsMock).toHaveBeenCalledOnce(); + expect((await callHandler({ channel: "channel-1", text: "Done" })).status).toBe(200); + expect(listChannelsMock).toHaveBeenCalledTimes(2); + }); + + it("checks the current binding even when the channel name is cached", async () => { + expect((await callHandler({ channel: "#ops", text: "Done" })).status).toBe(200); + channelBindingStoreMock.get.mockResolvedValue({ teamId: "team-b" }); + fetchMock.mockClear(); + const refused = await callHandler({ channel: "ops", text: "secret text" }); + expect(refused.status).toBe(403); + expect(await refused.json()).toMatchObject({ error: "session_scope_denied" }); + expect(listChannelsMock).toHaveBeenCalledOnce(); + expect(channelBindingStoreMock.get).toHaveBeenCalledTimes(2); + expect(channelBindingStoreMock.get).toHaveBeenLastCalledWith("slack", "C1"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(sessionFetchMock).not.toHaveBeenCalled(); + }); + + it("does not cache failed listings or missing names", async () => { + listChannelsMock.mockResolvedValueOnce({ ok: false, error: "ratelimited", retryAfter: 30 }); + const limited = await callHandler({ channel: "ops", text: "Done" }); + expect(limited.status).toBe(429); + expect(await limited.json()).toEqual({ + error: "rate_limited", + message: "ratelimited", + retryAfter: 30, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(channelBindingStoreMock.get).not.toHaveBeenCalled(); + expect((await callHandler({ channel: "ops", text: "Done" })).status).toBe(200); + fetchMock.mockClear(); + channelBindingStoreMock.get.mockClear(); + for (const channel of ["#missing", "missing"]) { + const missing = await callHandler({ channel, text: "Done" }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + error: "channel_not_found_or_forbidden", + message: "Slack channel was not found.", + }); + } + expect(listChannelsMock).toHaveBeenCalledTimes(4); + expect(fetchMock).not.toHaveBeenCalled(); + expect(channelBindingStoreMock.get).not.toHaveBeenCalled(); + }); + }); + + it("refuses a missing authoritative session before bot configuration", async () => { + sessionStoreMock.get.mockResolvedValue(null); + + const res = await callHandler( + { channel: "C1", text: "secret text" }, + { SLACK_BOT_TOKEN: undefined } + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_input" }); + expect(listChannelsMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses private sessions before bot token or settings lookup", async () => { + seedActiveSession({ visibility: "private" }); + + const res = await callHandler( + { channel: "#ops", text: "secret text" }, + { SLACK_BOT_TOKEN: undefined } + ); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "session_scope_denied" }); + expect(integrationStoreMock.getGlobal).not.toHaveBeenCalled(); + expect(integrationStoreMock.getResolvedConfig).not.toHaveBeenCalled(); + expect(listChannelsMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses when name resolution races a session becoming private", async () => { seedActiveSession(); integrationStoreMock.getResolvedConfig.mockResolvedValue({ - enabledRepos: null, - settings: { agentNotificationsEnabled: true, mentionsPolicy: "allow" }, + settings: { agentNotificationsEnabled: true }, + }); + listChannelsMock.mockImplementationOnce(async () => { + seedActiveSession({ visibility: "private" }); + return { ok: true, channels: [{ id: "C1", name: "ops" }] }; }); - mockSlackResponse({ body: { ok: true, channel: "C1", ts: "1.2" } }); - mockSlackResponse({ body: { ok: true, permalink: "https://x.slack.com/p", channel: "C1" } }); - await callHandler({ channel: "#ops", text: "hello" }); + const res = await callHandler({ channel: "#ops", text: "secret text" }); - expect(sessionFetchMock).not.toHaveBeenCalled(); + expect(res.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses when the session disappears during channel name resolution", async () => { + seedActiveSession(); + integrationStoreMock.getResolvedConfig.mockResolvedValue({ + settings: { agentNotificationsEnabled: true }, + }); + listChannelsMock.mockImplementationOnce(async () => { + sessionStoreMock.get.mockResolvedValue(null); + return { ok: true, channels: [{ id: "C1", name: "ops" }] }; + }); + + const res = await callHandler({ channel: "#ops", text: "secret text" }); + + expect(res.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); }); it("returns 503 feature_unavailable and logs at error level when SLACK_BOT_TOKEN is missing", async () => { @@ -211,24 +402,6 @@ describe("handleSlackNotify", () => { expect(sessionFetchMock).not.toHaveBeenCalled(); }); - // The handler reads only the resolved master switch (returned by - // getResolvedConfig, which already merges global + repo). Whether the - // resolved `false` came from a global default or a repo override is not - // the handler's concern — that resolution is covered by - // IntegrationSettingsStore tests in db/integration-settings.test.ts. - it("does not call Slack when feature_disabled regardless of resolution source", async () => { - seedActiveSession(); - integrationStoreMock.getResolvedConfig.mockResolvedValue({ - enabledRepos: null, - settings: { agentNotificationsEnabled: false, mentionsPolicy: "allow" }, - }); - - const res = await callHandler({ channel: "#ops", text: "hello" }); - - expect(res.status).toBe(403); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it("maps Slack channel_not_found to channel_not_found_or_forbidden", async () => { seedActiveSession(); integrationStoreMock.getResolvedConfig.mockResolvedValue({ @@ -399,7 +572,7 @@ describe("handleSlackNotify", () => { channel: string; blocks: Array<{ type: string; text?: { text: string } }>; }; - expect(sentBody.channel).toBe("#ops"); + expect(sentBody.channel).toBe("C1"); const sentText = sentBody.blocks.find((block) => block.type === "section")?.text?.text ?? ""; expect(sentText).not.toContain(""); expect(sentText).not.toContain("<@U999>"); @@ -536,7 +709,7 @@ describe("handleSlackNotify", () => { expect(logEntry?.request_reason).toBeNull(); }); - it("passes channel input verbatim to Slack — channel ID", async () => { + it("passes a channel ID without a Slack channel lookup", async () => { seedActiveSession(); integrationStoreMock.getResolvedConfig.mockResolvedValue({ enabledRepos: null, @@ -551,36 +724,45 @@ describe("handleSlackNotify", () => { channel: string; }; expect(sentBody.channel).toBe("C01ABC"); + expect(listChannelsMock).not.toHaveBeenCalled(); }); - it("passes channel input verbatim to Slack — name with hash", async () => { - seedActiveSession(); - integrationStoreMock.getResolvedConfig.mockResolvedValue({ - enabledRepos: null, - settings: { agentNotificationsEnabled: true, mentionsPolicy: "allow" }, - }); - mockSlackResponse({ body: { ok: true, channel: "C123", ts: "1.2" } }); - mockSlackResponse({ body: { ok: true, permalink: "https://x.slack.com/p", channel: "C1" } }); - - await callHandler({ channel: "#ops", text: "hi" }); - - const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { - channel: string; - }; - expect(sentBody.channel).toBe("#ops"); - }); - - it("does not call Slack when feature is disabled", async () => { - seedActiveSession(); - integrationStoreMock.getResolvedConfig.mockResolvedValue({ - enabledRepos: null, - settings: { agentNotificationsEnabled: false, mentionsPolicy: "allow" }, - }); - - await callHandler({ channel: "#ops", text: "hi" }); + it.each([true, false])( + "resolves names before binding checks and posting (permalink channel: %s)", + async (hasPermalinkChannel) => { + seedActiveSession({ visibility: "team" }); + channelBindingStoreMock.get.mockResolvedValue({ teamId: "team-a" }); + integrationStoreMock.getResolvedConfig.mockResolvedValue({ + enabledRepos: null, + settings: { agentNotificationsEnabled: true, mentionsPolicy: "allow" }, + }); + mockSlackResponse({ body: { ok: true, channel: "C123", ts: "1.2" } }); + mockSlackResponse({ + body: { + ok: true, + permalink: "https://x.slack.com/p", + ...(hasPermalinkChannel ? { channel: "C1" } : {}), + }, + }); - expect(fetchMock).not.toHaveBeenCalled(); - }); + const response = await callHandler({ channel: "#ops", text: "hi" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + channelId: "C123", + messageTs: "1.2", + permalink: hasPermalinkChannel ? "https://x.slack.com/p" : "", + }); + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { + channel: string; + }; + expect(sentBody.channel).toBe("C1"); + expect(listChannelsMock).toHaveBeenCalledWith("xoxb-test", { + signal: expect.any(AbortSignal), + }); + expect(channelBindingStoreMock.get).toHaveBeenCalledWith("slack", "C1"); + } + ); it("maps Slack network/fetch failures to slack_api_error", async () => { seedActiveSession(); diff --git a/packages/control-plane/src/routes/slack-notify.ts b/packages/control-plane/src/routes/slack-notify.ts index 368a56e1de..da24ed9238 100644 --- a/packages/control-plane/src/routes/slack-notify.ts +++ b/packages/control-plane/src/routes/slack-notify.ts @@ -8,6 +8,7 @@ import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { getPermalink, + listChannels, postBlocks, sanitizeAgentText, splitIntoSlackSections, @@ -18,6 +19,8 @@ import { import type { SlackGlobalSettings } from "@open-inspect/shared/types/integrations"; import { IntegrationSettingsStore, resolveSlackSettings } from "../db/integration-settings"; import { SessionIndexStore } from "../db/session-index"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; +import { slackPostGate } from "../authorization/slack-post-gate"; import { createLogger } from "../logger"; import type { Env } from "../types"; import { GITHUB_SANDBOX_FALLBACK_ROUTE, json, requireSession, type RequestContext } from "./shared"; @@ -35,6 +38,19 @@ const RAW_TEXT_INPUT_MAX_LENGTH = 12_000; const CHANNEL_INPUT_MAX_LENGTH = 80; /** Reason field cap; recorded for audit only. */ const REASON_MAX_LENGTH = 500; +const CHANNEL_NAME_CACHE_TTL_MS = 60_000; +const CHANNEL_NAME_CACHE_MAX_ENTRIES = 1_000; +const channelNameCache = new Map(); + +function cacheChannelName(token: string, channel: { id: string; name: string }, expiresAt: number) { + const key = JSON.stringify([token, channel.name.toLowerCase()]); + channelNameCache.delete(key); + channelNameCache.set(key, { id: channel.id, expiresAt }); + if (channelNameCache.size > CHANNEL_NAME_CACHE_MAX_ENTRIES) { + const oldestKey = channelNameCache.keys().next().value; + if (oldestKey !== undefined) channelNameCache.delete(oldestKey); + } +} interface ParsedBody { channel: string; @@ -61,7 +77,8 @@ export async function handleSlackNotify( const parsed = await parseBody(request); if (parsed instanceof Response) return parsed; - const session = await new SessionIndexStore(ctx.db).get(sessionId); + const sessionStore = new SessionIndexStore(ctx.db); + const session = await sessionStore.get(sessionId); if (!session) { return failureResponse("invalid_input", "Session not found."); } @@ -75,6 +92,11 @@ export async function handleSlackNotify( repo: repoScope, }; + if (slackPostGate(session, null)) { + logDenial(sessionId, ctx, parsed, audit, "session_scope_denied"); + return failureResponse("session_scope_denied", "This session cannot post to Slack."); + } + const token = env.SLACK_BOT_TOKEN; if (!token) { // Error (not warn): a missing token is a deployment misconfig and must reach alerting. @@ -120,6 +142,47 @@ export async function handleSlackNotify( ); } + let targetChannelId = parsed.channel; + if (!/^[CDG][A-Z0-9]+$/.test(targetChannelId)) { + const name = parsed.channel.replace(/^#/, "").toLowerCase(); + const cacheKey = JSON.stringify([token, name]); + const cached = channelNameCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + targetChannelId = cached.id; + } else { + channelNameCache.delete(cacheKey); + const listing = await listChannels(token, { signal: request.signal }); + if (!listing.ok) { + const reason = mapSlackError(listing.error); + logDenial(sessionId, ctx, parsed, audit, reason, listing.retryAfter); + return failureResponse(reason, listing.error, listing.retryAfter); + } + const expiresAt = Date.now() + CHANNEL_NAME_CACHE_TTL_MS; + for (const channel of listing.channels) cacheChannelName(token, channel, expiresAt); + const channel = listing.channels.find((candidate) => candidate.name.toLowerCase() === name); + if (!channel) { + logDenial(sessionId, ctx, parsed, audit, "channel_not_found_or_forbidden"); + return failureResponse("channel_not_found_or_forbidden", "Slack channel was not found."); + } + // A large listing must not evict the name this request actually resolved. + cacheChannelName(token, channel, expiresAt); + targetChannelId = channel.id; + } + } + + // Re-read after name resolution: only the authoritative, current row permits publication. + const [currentSession, channelBinding] = await Promise.all([ + sessionStore.get(sessionId), + new TeamChannelBindingStore(ctx.db).get("slack", targetChannelId), + ]); + if (slackPostGate(currentSession, channelBinding)) { + logDenial(sessionId, ctx, parsed, audit, "session_scope_denied"); + return failureResponse( + "session_scope_denied", + "This session cannot post to this Slack channel." + ); + } + const sections = splitIntoSlackSections(sanitized.text); const blocks = buildBlocks({ sections, @@ -128,7 +191,7 @@ export async function handleSlackNotify( webAppUrl: env.WEB_APP_URL, }); // Without top-level text, Slack derives screen-reader text from the blocks. - const post = await postBlocks(token, parsed.channel, blocks, { + const post = await postBlocks(token, targetChannelId, blocks, { thread_ts: parsed.threadTs, signal: request.signal, }); diff --git a/packages/control-plane/src/routes/team-channel-bindings.ts b/packages/control-plane/src/routes/team-channel-bindings.ts new file mode 100644 index 0000000000..f9696fe8ac --- /dev/null +++ b/packages/control-plane/src/routes/team-channel-bindings.ts @@ -0,0 +1,153 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { computeHmacHex } from "@open-inspect/shared/auth"; +import { + putTeamChannelBindingRequestSchema, + teamChannelBindingProviderSchema, + teamChannelBindingResponseSchema, + teamChannelBindingsResponseSchema, +} from "@open-inspect/shared/types/team-channel-bindings"; +import { callbackSigningSecret } from "../auth/service/callback-signing"; +import { + TeamChannelBindingConflictError, + TeamChannelBindingStore, +} from "../db/team-channel-bindings"; +import type { RequestContext } from "../http/request-context"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import { parseBody } from "./body"; +import { handleGetSlackChannels } from "./automation-slack-settings"; +import { SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, error, json, requireTeam } from "./shared"; + +const slackChannelInfoSchema = z.object({ + id: z.string(), + name: z.string(), + isMember: z.boolean(), + isExtShared: z.boolean(), +}); + +function admittedTeamId(ctx: RequestContext): string { + if (!ctx.teamAdmission) throw new Error("Team route not admitted"); + return ctx.teamAdmission.team.id; +} + +async function listBindings( + _request: Request, + _env: Env, + _params: { id: string }, + ctx: RequestContext +) { + return json( + teamChannelBindingsResponseSchema.parse({ + bindings: await new TeamChannelBindingStore(ctx.db).listByTeam(admittedTeamId(ctx)), + }) + ); +} + +async function putBinding( + request: Request, + env: Env, + params: { id: string; provider: string; externalId: string }, + ctx: RequestContext +) { + if (params.provider !== "slack") return error("Unsupported channel binding provider", 400); + const body = await parseBody(request, putTeamChannelBindingRequestSchema); + if (body instanceof Response) return body; + const secret = callbackSigningSecret(env, "slack-bot"); + if (!env.SLACK_BOT || !secret) { + return json( + { error: "Channel information unavailable", code: "channel_info_unavailable" }, + 503 + ); + } + + const payload = { channelId: params.externalId, timestamp: Date.now() }; + const signature = await computeHmacHex(JSON.stringify(payload), secret); + let response: Response; + try { + response = await env.SLACK_BOT.fetch("https://internal/internal/channel-info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, signature }), + signal: request.signal, + }); + } catch { + return json( + { error: "Channel information unavailable", code: "channel_info_unavailable" }, + 503 + ); + } + const info = slackChannelInfoSchema.safeParse( + response.ok ? await response.json().catch(() => null) : null + ); + if ( + !info.success || + info.data.id !== params.externalId || + !info.data.isMember || + info.data.isExtShared + ) { + return json({ error: "Channel cannot be bound", code: "channel_not_joinable" }, 409); + } + + if (ctx.principal?.kind !== "user") throw new Error("Team route not admitted"); + try { + const binding = await new TeamChannelBindingStore(ctx.db).put( + { + teamId: admittedTeamId(ctx), + provider: "slack", + externalId: params.externalId, + kind: body.kind, + }, + { requestId: ctx.request_id, actorUserId: ctx.principal.userId } + ); + return json(teamChannelBindingResponseSchema.parse({ binding })); + } catch (cause) { + if (cause instanceof TeamChannelBindingConflictError) { + return json({ error: cause.message, code: "channel_binding_conflict" }, 409); + } + throw cause; + } +} + +async function deleteBinding( + _request: Request, + _env: Env, + params: { id: string; provider: string; externalId: string }, + ctx: RequestContext +) { + const provider = teamChannelBindingProviderSchema.safeParse(params.provider); + if (!provider.success) return error("Unsupported channel binding provider", 400); + if (ctx.principal?.kind !== "user") throw new Error("Team route not admitted"); + await new TeamChannelBindingStore(ctx.db).remove( + admittedTeamId(ctx), + provider.data, + params.externalId, + { requestId: ctx.request_id, actorUserId: ctx.principal.userId } + ); + return new Response(null, { status: 204 }); +} + +export const teamChannelBindingRoutes = new Hono(); +const manageBindings = admit({ + ...SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, + cacheControl: "private, no-store", + authorization: requireTeam("canManageBindings"), +}); + +teamChannelBindingRoutes.get("/teams/:id/channel-bindings", manageBindings, (c) => + dispatch(c, listBindings) +); +teamChannelBindingRoutes.put( + "/teams/:id/channel-bindings/:provider/:externalId", + manageBindings, + (c) => dispatch(c, putBinding) +); +teamChannelBindingRoutes.delete( + "/teams/:id/channel-bindings/:provider/:externalId", + manageBindings, + (c) => dispatch(c, deleteBinding) +); +teamChannelBindingRoutes.get("/teams/:id/slack-channels", manageBindings, (c) => + dispatch(c, handleGetSlackChannels) +); diff --git a/packages/control-plane/src/routes/team-ownership.ts b/packages/control-plane/src/routes/team-ownership.ts index c02187abe6..b4bfd24427 100644 --- a/packages/control-plane/src/routes/team-ownership.ts +++ b/packages/control-plane/src/routes/team-ownership.ts @@ -5,7 +5,9 @@ import { isWorkspaceAdmin } from "@open-inspect/shared/rbac"; import type { Team } from "@open-inspect/shared/types/teams"; +import { parseChannelScope } from "../authorization/channel-scope"; import { auditRouteAuthorizationDecision } from "../authorization/request-audit"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; import { TeamMembershipStore } from "../db/team-memberships"; import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; import { TeamSettingsStore } from "../db/team-settings"; @@ -15,6 +17,48 @@ import type { RequestContext } from "../http/request-context"; export type TeamRepositoryGrants = Awaited>; +/** Null is unscoped; an explicit workspace scope must not include other teams' resources. */ +export async function resolveCatalogScope( + request: Request, + ctx: RequestContext, + catalogTeamId: string | null, + path: string +): Promise<{ teamId: string | null; grants: TeamRepositoryGrants | null } | null | Response> { + const query = new URL(request.url).searchParams; + const channels = query.getAll("channel"); + if (channels.length > 0) { + const refusal = { error: "Slack channel scope denied", code: "slack_channel_scope_denied" }; + const scope = channels.length === 1 ? parseChannelScope(channels[0]) : null; + if (!scope || scope.provider !== "slack" || query.has("teamId")) return json(refusal, 400); + if ( + ctx.principal?.kind !== "service" || + ctx.principal.service !== "slack-bot" || + !ctx.authorization + ) { + return json(refusal, 403); + } + try { + const teamId = + (await new TeamChannelBindingStore(ctx.db).get("slack", scope.externalId))?.teamId ?? null; + const grants = teamId === null ? null : await admitTeamCatalog(request, ctx, teamId, path); + return grants instanceof Response ? grants : { teamId, grants }; + } catch { + return json(refusal, 503); + } + } + if (query.getAll("teamId").length > 1) return error("Invalid teamId", 400); + if ( + query.has("teamId") && + ctx.principal?.kind === "service" && + ctx.principal.service === "slack-bot" + ) { + return denyTeamCatalog(request, ctx, catalogTeamId, path); + } + if (catalogTeamId === null) return null; + const grants = await admitTeamCatalog(request, ctx, catalogTeamId, path); + return grants instanceof Response ? grants : { teamId: catalogTeamId, grants }; +} + export function teamRequiredResponse(): Response { return json({ error: "A team is required", code: "team_required" }, 400); } @@ -53,7 +97,7 @@ export async function resolveActiveTeam( } /** - * Admit a `?teamId=` session catalog and return the team's repository grants. The team must be + * Admit a team's session catalog and return its repository grants. The team must be * active and the caller a member or workspace admin; hidden teams are audited and answered * like missing ones. */ @@ -73,22 +117,31 @@ export async function admitTeamCatalog( authorization.userId )).has(catalogTeamId)); if (!allowed) { - const response = error("Team not found", 404); - await auditRouteAuthorizationDecision({ - ctx, - method: request.method, - path, - response, - teamId: catalogTeamId, - decision: { - kind: "denied", - reasonCode: "team_not_visible", - reason: "Team not found", - requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], - effectivePermissions: [], - }, - }); - return response; + return denyTeamCatalog(request, ctx, catalogTeamId, path); } return new TeamRepositoryGrantStore(ctx.db).listForTeam(catalogTeamId); } + +async function denyTeamCatalog( + request: Request, + ctx: RequestContext, + teamId: string | null, + path: string +): Promise { + const response = error("Team not found", 404); + await auditRouteAuthorizationDecision({ + ctx, + method: request.method, + path, + response, + teamId, + decision: { + kind: "denied", + reasonCode: "team_not_visible", + reason: "Team not found", + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + effectivePermissions: [], + }, + }); + return response; +} diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index 23aac72c34..df3c5f8249 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -13,10 +13,13 @@ import type { } from "../authorization/request-audit"; import { AuthorizationError, AuthorizationService } from "../authorization/service"; import { serviceAllowsPermission } from "../authorization/service-permissions"; +import { parseChannelScope } from "../authorization/channel-scope"; import { evaluateSessionAdmission, viewerFromContext } from "../authorization/session-admission"; import { legacyPermissionForAction } from "../authorization/teams-enforcement"; import { TeamStore } from "../db/teams"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; import { TeamMembershipStore } from "../db/team-memberships"; +import { SessionIndexStore } from "../db/session-index"; import { resolveTeamAccess } from "@open-inspect/shared/types/team-access"; import { UserStore } from "../db/user-store"; import type { RequestContext } from "../http/request-context"; @@ -368,6 +371,55 @@ function enforceStaticServicePermissionCeiling( return null; } +async function enforceSlackWriteScope( + params: RouteParams, + request: Request, + pathname: string, + ctx: RequestContext, + evidence: AuthorizationEvidence +): Promise { + if ( + ctx.principal?.kind !== "service" || + ctx.principal.service !== "slack-bot" || + request.method !== "POST" || + !/^\/sessions\/[^/]+\/(prompt|attachments)$/.test(pathname) + ) { + return null; + } + + const refusal = { error: "Slack channel scope denied", code: "slack_channel_scope_denied" }; + const deny = (status: 400 | 403 | 404): AuthorizationFailure => + authorizationDenial( + json(refusal, status), + evidence, + { kind: "session", sessionIdParam: "id", action: "collaborate" }, + refusal.code, + refusal.error + ); + const channels = new URL(request.url).searchParams.getAll("channel"); + const scope = channels.length === 1 ? parseChannelScope(channels[0]) : null; + if (!scope || scope.provider !== "slack") return deny(400); + + try { + const [binding, session] = await Promise.all([ + new TeamChannelBindingStore(ctx.db).get("slack", scope.externalId), + new SessionIndexStore(ctx.db).get(params.id), + ]); + if (!session) return deny(404); + if ((binding?.teamId ?? null) !== session.ownerTeamId) return deny(403); + // Actor collaboration is authorized separately after this live channel check. + return null; + } catch (cause) { + logger.error("Slack channel scope authorization unavailable", { + event: "authorization.slack_channel_scope_unavailable", + error: cause instanceof Error ? cause : String(cause), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { response: json(refusal, 503) }; + } +} + /** * Resolve the verified service actor to its canonical user exactly once, * before any RBAC lookup, so the subject authorized is the subject attributed. @@ -674,6 +726,7 @@ async function enforceTeamRequirement( async function enforceSessionRequirement( requirement: Extract, params: RouteParams, + request: Request, env: Env, ctx: RequestContext, evidence: AuthorizationEvidence @@ -681,6 +734,36 @@ async function enforceSessionRequirement( const sessionId = params[requirement.sessionIdParam]; if (!sessionId) return { response: json({ error: "Invalid session route" }, 400) }; try { + if (ctx.principal?.kind === "service" && !ctx.principal.actor) { + const query = new URL(request.url).searchParams; + const channels = query.getAll("channel"); + const postRead = query.get("purpose") === "slack-post"; + if (postRead && (channels.length !== 1 || ctx.principal.service !== "slack-bot")) { + return authorizationDenial( + error("Session not found", 404), + evidence, + requirement, + "session_not_visible", + "Session not found" + ); + } + if (channels.length > 0) { + const scope = channels.length === 1 ? parseChannelScope(channels[0]) : null; + if (!scope || ctx.principal.service !== `${scope.provider}-bot`) { + return authorizationDenial( + error("Session not found", 404), + evidence, + requirement, + "session_not_visible", + "Session not found" + ); + } + ctx.serviceTeamId = + (await new TeamChannelBindingStore(ctx.db).get(scope.provider, scope.externalId)) + ?.teamId ?? null; + if (postRead) ctx.serviceReadPurpose = "slack-post"; + } + } const result = await evaluateSessionAdmission( ctx, env, @@ -743,7 +826,7 @@ function allowed( /** * Ordered trust transition for an authenticated request: principal kind, - * sandbox capability, service capability and ceiling, actor finalization, + * sandbox capability, service capability and ceiling, Slack write scope, actor finalization, * active canonical subject, then route permission and resource requirements. */ async function enforceRouteAuthorization( @@ -781,6 +864,9 @@ async function enforceRouteAuthorization( const ceilingFailure = enforceStaticServicePermissionCeiling(policy, ctx, evidence); if (ceilingFailure) return resultForFailure(ceilingFailure); + const scopeFailure = await enforceSlackWriteScope(params, request, pathname, ctx, evidence); + if (scopeFailure) return resultForFailure(scopeFailure); + const actorFailure = await finalizeServiceActor(policy, request, pathname, env, ctx); if (actorFailure) return resultForFailure(actorFailure); @@ -802,7 +888,14 @@ async function enforceRouteAuthorization( failure = await enforceTeamRequirement(requirement, params, ctx, evidence); break; case "session": - failure = await enforceSessionRequirement(requirement, params, env, ctx, evidence); + failure = await enforceSessionRequirement( + requirement, + params, + request, + env, + ctx, + evidence + ); break; } if (failure) return resultForFailure(failure); diff --git a/packages/control-plane/src/scheduler/scheduler.test.ts b/packages/control-plane/src/scheduler/scheduler.test.ts index b99fe834df..84ccf3d3bd 100644 --- a/packages/control-plane/src/scheduler/scheduler.test.ts +++ b/packages/control-plane/src/scheduler/scheduler.test.ts @@ -15,6 +15,7 @@ import { fakeSessionRuntimeDispatch } from "../router.test-support"; import type { Logger } from "../logger"; import type { AutomationRow, InvocationRunAggregate } from "../db/automation-store"; import type { SlackAutomationEvent } from "@open-inspect/shared/triggers"; +import { verifyCallbackSignature } from "@open-inspect/shared/auth"; import type { Team } from "@open-inspect/shared/types/teams"; import type { EffectiveAuthorization } from "@open-inspect/shared/rbac"; import type * as SessionAdmissionModule from "../authorization/session-admission"; @@ -195,11 +196,19 @@ vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { const mockSessionStoreCreate = vi.fn().mockResolvedValue(undefined); const mockSessionStoreUpdateStatus = vi.fn().mockResolvedValue(undefined); +const mockSessionStoreGet = vi.fn(); +const mockTeamChannelBindingGet = vi.fn(); +vi.mock("../db/team-channel-bindings", () => ({ + TeamChannelBindingStore: vi.fn().mockImplementation(function () { + return { get: mockTeamChannelBindingGet }; + }), +})); vi.mock("../db/session-index", () => ({ SessionIndexStore: vi.fn().mockImplementation(function () { return { create: mockSessionStoreCreate, updateStatus: mockSessionStoreUpdateStatus, + get: mockSessionStoreGet, }; }), })); @@ -2098,6 +2107,241 @@ describe("Scheduler", () => { describe("runComplete", () => { beforeEach(() => { mockStore.getRunById.mockResolvedValue(sampleRunRow()); + mockSessionStoreGet + .mockReset() + .mockResolvedValue({ ownerTeamId: "team-a", visibility: "workspace" }); + mockTeamChannelBindingGet.mockReset().mockResolvedValue(null); + }); + + describe("Slack publication preparation retries", () => { + function createSlackCompletionHarness(overrides?: Partial) { + mockStore.getInvocationById.mockResolvedValue({ + trigger_metadata: JSON.stringify({ channel: "C1", messageTs: "1700000000.000200" }), + }); + mockStore.getById.mockResolvedValue(sampleSlackAutomation); + const slackFetch = vi.fn().mockResolvedValue(new Response("ok")); + const scheduler = createScheduler( + createEnv({ + SLACK_BOT: { fetch: slackFetch }, + SERVICE_AUTH_SECRET_SLACK_BOT: "test-secret", + ...overrides, + }) + ); + const warn = vi + .spyOn((scheduler as unknown as { log: Logger }).log, "warn") + .mockImplementation(() => {}); + return { scheduler, slackFetch, warn }; + } + + it.each(["none", "invocation", "automation", "session", "channel"] as const)( + "reads invocation coordinates and run-snapshot labels after %s preparation failure", + async (lookup) => { + const { scheduler, slackFetch, warn } = createSlackCompletionHarness(); + mockStore.getRunById.mockResolvedValue( + sampleRunRow({ + automation_id: "auto-slack", + invocation_id: "inv-slack", + trigger_run_metadata: null, + }) + ); + mockStore.getById.mockResolvedValue({ + ...sampleSlackAutomation, + repo_name: "changed-repository", + }); + mockTeamChannelBindingGet.mockResolvedValue({ teamId: "team-a" }); + const read = + lookup === "none" + ? null + : { + invocation: mockStore.getInvocationById, + automation: mockStore.getById, + session: mockSessionStoreGet, + channel: mockTeamChannelBindingGet, + }[lookup]; + read?.mockRejectedValueOnce(new Error("D1 unavailable")); + + await expect( + scheduler.runComplete(runCompletion({ automationId: "auto-slack" })) + ).resolves.toBeUndefined(); + + expect(mockStore.updateRun).toHaveBeenCalledOnce(); + expect(mockStore.getInvocationRunAggregate).toHaveBeenCalledOnce(); + expect(mockStore.getInvocationById).toHaveBeenCalledWith("inv-slack"); + expect(mockStore.getInvocationById).toHaveBeenCalledTimes(read ? 2 : 1); + expect(mockSessionStoreGet).toHaveBeenCalledWith("sess-1"); + expect(mockTeamChannelBindingGet).toHaveBeenCalledWith("slack", "C1"); + expect(slackFetch).toHaveBeenCalledOnce(); + expect(slackFetch.mock.calls[0][0]).toBe( + "https://internal/callbacks/automation-complete" + ); + const body = JSON.parse(String(slackFetch.mock.calls[0][1]?.body)); + expect(body).toMatchObject({ + channel: "C1", + reactionMessageTs: "1700000000.000200", + repoFullName: "acme/web-app", + sessionId: "sess-1", + messageId: "msg-1", + success: true, + }); + expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + if (read) { + expect(read).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledExactlyOnceWith( + "Slack completion callback failed", + expect.objectContaining({ event: "scheduler.slack_complete_failed", attempt: 1 }) + ); + } else { + expect(warn).not.toHaveBeenCalled(); + } + } + ); + + it.each([ + { visibility: "private", binding: null, reason: "private_session" }, + { + visibility: "workspace", + binding: { teamId: "team-b" }, + reason: "channel_team_mismatch", + }, + ])("selects only closure after recovering $reason", async ({ visibility, binding }) => { + const { scheduler, slackFetch } = createSlackCompletionHarness(); + mockSessionStoreGet + .mockRejectedValueOnce(new Error("D1 unavailable")) + .mockResolvedValue({ ownerTeamId: "team-a", visibility }); + mockTeamChannelBindingGet.mockResolvedValue(binding); + slackFetch.mockResolvedValue(new Response("unavailable", { status: 503 })); + + await expect( + scheduler.runComplete(runCompletion({ success: false, error: "secret error" })) + ).resolves.toBeUndefined(); + + expect(mockStore.updateRun).toHaveBeenCalledOnce(); + expect(mockStore.getInvocationById).toHaveBeenCalledTimes(2); + expect(mockSessionStoreGet).toHaveBeenCalledTimes(2); + expect(slackFetch).toHaveBeenCalledOnce(); + expect(slackFetch.mock.calls[0][0]).toBe("https://internal/callbacks/thread_closed"); + const body = JSON.parse(String(slackFetch.mock.calls[0][1]?.body)); + expect(body).toEqual({ + kind: "slack.thread_closed", + sessionId: "sess-1", + timestamp: expect.any(Number), + context: { channel: "C1", threadTs: "1700000000.000200" }, + signature: expect.any(String), + }); + expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + }); + + it("rechecks preparation and scope after transport failure without a nested loop", async () => { + const { scheduler, slackFetch, warn } = createSlackCompletionHarness(); + mockTeamChannelBindingGet + .mockResolvedValueOnce(null) + .mockResolvedValue({ teamId: "team-b" }); + slackFetch.mockResolvedValue(new Response("unavailable", { status: 503 })); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + try { + await scheduler.runComplete(runCompletion({ success: false, error: "secret error" })); + + expect(mockStore.updateRun).toHaveBeenCalledOnce(); + expect(mockStore.getInvocationById).toHaveBeenCalledTimes(2); + expect(mockStore.getById).toHaveBeenCalledTimes(2); + expect(mockSessionStoreGet).toHaveBeenCalledTimes(2); + expect(mockTeamChannelBindingGet).toHaveBeenCalledTimes(2); + expect(slackFetch.mock.calls.map(([url]) => url)).toEqual([ + "https://internal/callbacks/automation-complete", + "https://internal/callbacks/thread_closed", + ]); + const closure = JSON.parse(String(slackFetch.mock.calls[1][1]?.body)); + expect(closure.context).toEqual({ channel: "C1", threadTs: "1700000000.000200" }); + expect(closure).not.toHaveProperty("error"); + expect(closure).not.toHaveProperty("messageId"); + expect(setTimeoutSpy.mock.calls.map(([, ms]) => ms)).toEqual([10_000, 1000, 10_000]); + expect( + warn.mock.calls + .filter(([, fields]) => fields?.event === "scheduler.slack_complete_failed") + .map(([, fields]) => fields?.attempt) + ).toEqual([1, 2]); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + + it.each(["invocation", "automation", "session", "channel"] as const)( + "contains exhausted %s reads without transport or terminal duplicate publication", + async (lookup) => { + const { scheduler, slackFetch, warn } = createSlackCompletionHarness(); + const read = { + invocation: mockStore.getInvocationById, + automation: mockStore.getById, + session: mockSessionStoreGet, + channel: mockTeamChannelBindingGet, + }[lookup]; + read.mockRejectedValue(new Error("D1 unavailable")); + mockStore.updateRun.mockResolvedValueOnce(true).mockResolvedValue(false); + + await expect(scheduler.runComplete(runCompletion())).resolves.toBeUndefined(); + await expect(scheduler.runComplete(runCompletion())).resolves.toBeUndefined(); + + expect(read).toHaveBeenCalledTimes(2); + expect(mockStore.getInvocationById).toHaveBeenCalledTimes(2); + expect(mockStore.getInvocationRunAggregate).toHaveBeenCalledOnce(); + expect(slackFetch).not.toHaveBeenCalled(); + expect(warn.mock.calls.map(([, fields]) => fields?.attempt)).toEqual([1, 2, undefined]); + } + ); + + it("does not transport a timed-out attempt after its uncancelable D1 read finishes", async () => { + vi.useFakeTimers(); + try { + const { scheduler, slackFetch } = createSlackCompletionHarness(); + const pendingRead = deferred(); + const readStarted = deferred(); + mockSessionStoreGet.mockImplementationOnce(() => { + readStarted.resolve(); + return pendingRead.promise; + }); + const completion = scheduler.runComplete(runCompletion()); + + await readStarted.promise; + await vi.advanceTimersByTimeAsync(10_000); + pendingRead.resolve({ ownerTeamId: "team-a", visibility: "workspace" }); + await vi.advanceTimersByTimeAsync(1000); + await completion; + + expect(mockStore.updateRun).toHaveBeenCalledOnce(); + expect(mockSessionStoreGet).toHaveBeenCalledTimes(2); + expect(slackFetch).toHaveBeenCalledOnce(); + expect(slackFetch.mock.calls[0][1]?.signal.aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it.each([{ SLACK_BOT: undefined }, { SERVICE_AUTH_SECRET_SLACK_BOT: undefined }])( + "skips publication preparation when callback configuration is absent: %j", + async (overrides) => { + const { scheduler, slackFetch } = createSlackCompletionHarness(overrides); + + await scheduler.runComplete(runCompletion()); + + expect(mockStore.updateRun).toHaveBeenCalledOnce(); + expect(mockStore.getInvocationById).not.toHaveBeenCalled(); + expect(mockStore.getById).not.toHaveBeenCalled(); + expect(mockSessionStoreGet).not.toHaveBeenCalled(); + expect(slackFetch).not.toHaveBeenCalled(); + } + ); + + it("does not republish a successful winning callback on a terminal duplicate", async () => { + const { scheduler, slackFetch } = createSlackCompletionHarness(); + mockStore.updateRun.mockResolvedValueOnce(true).mockResolvedValue(false); + + await scheduler.runComplete(runCompletion()); + await scheduler.runComplete(runCompletion()); + + expect(mockStore.getInvocationById).toHaveBeenCalledOnce(); + expect(mockStore.getInvocationRunAggregate).toHaveBeenCalledOnce(); + expect(slackFetch).toHaveBeenCalledOnce(); + }); }); it("marks run as completed and resets failures once every sibling completed", async () => { @@ -2144,57 +2388,6 @@ describe("Scheduler", () => { expect(mockStore.incrementConsecutiveFailures).not.toHaveBeenCalled(); }); - it("reads slack coordinates from the invocation and labels from the run snapshot", async () => { - mockStore.getRunById.mockResolvedValue( - sampleRunRow({ - automation_id: "auto-slack", - invocation_id: "inv-slack", - trigger_run_metadata: null, - }) - ); - mockStore.getInvocationById.mockResolvedValue({ - id: "inv-slack", - automation_id: "auto-slack", - source: "event", - scheduled_at: null, - trigger_key: "slack:msg:C1:1700000000.000200", - concurrency_key: "slack:C1:thread-root", - trigger_metadata: JSON.stringify({ channel: "C1", messageTs: "1700000000.000200" }), - skip_reason: null, - failure_counted_at: null, - created_at: now, - updated_at: now, - }); - mockStore.getById.mockResolvedValue(sampleSlackAutomation); - mockStore.getInvocationRunAggregate.mockResolvedValue( - aggregate({ total: 1, active: 0, failed: 0, completed: 1 }) - ); - - const slackFetch = vi.fn().mockResolvedValue(Response.json({ ok: true })); - const scheduler = createScheduler( - createEnv({ - SLACK_BOT: { fetch: slackFetch } as FetchClient, - SERVICE_AUTH_SECRET_SLACK_BOT: "test-secret", - }) - ); - - const result = await scheduler.runComplete(runCompletion({ automationId: "auto-slack" })); - - expect(result).toBeUndefined(); - expect(slackFetch).toHaveBeenCalledOnce(); - const [, init] = slackFetch.mock.calls[0]; - const body = JSON.parse(String(init?.body)) as Record; - expect(body).toMatchObject({ - channel: "C1", - reactionMessageTs: "1700000000.000200", - // Label reads the run's snapshot, not the automation row. - repoFullName: "acme/web-app", - sessionId: "sess-1", - messageId: "msg-1", - }); - expect(body.signature).toEqual(expect.any(String)); - }); - it("still posts to slack when a late success corrects a swept timeout", async () => { // The sweep posts nothing when it declares a run lost, so the correction // is the only chance to clear the `eyes` reaction on the triggering message. @@ -2209,17 +2402,7 @@ describe("Scheduler", () => { mockStore.updateRun.mockResolvedValue(false); mockStore.completeTimedOutRun.mockResolvedValue(true); mockStore.getInvocationById.mockResolvedValue({ - id: "inv-slack", - automation_id: "auto-slack", - source: "event", - scheduled_at: null, - trigger_key: "slack:msg:C1:1700000000.000200", - concurrency_key: "slack:C1:thread-root", trigger_metadata: JSON.stringify({ channel: "C1", messageTs: "1700000000.000200" }), - skip_reason: null, - failure_counted_at: null, - created_at: now, - updated_at: now, }); mockStore.getById.mockResolvedValue(sampleSlackAutomation); @@ -2253,17 +2436,7 @@ describe("Scheduler", () => { }) ); mockStore.getInvocationById.mockResolvedValue({ - id: "inv-slack", - automation_id: "auto-slack", - source: "event", - scheduled_at: null, - trigger_key: "slack:msg:C1:1700000000.000200", - concurrency_key: "slack:C1:thread-root", trigger_metadata: JSON.stringify({ channel: "C1", messageTs: "1700000000.000200" }), - skip_reason: null, - failure_counted_at: null, - created_at: now, - updated_at: now, }); mockStore.getById.mockResolvedValue({ ...sampleSlackAutomation, diff --git a/packages/control-plane/src/scheduler/scheduler.ts b/packages/control-plane/src/scheduler/scheduler.ts index 738d6e01eb..4e862c2303 100644 --- a/packages/control-plane/src/scheduler/scheduler.ts +++ b/packages/control-plane/src/scheduler/scheduler.ts @@ -53,13 +53,15 @@ import { toProviderSelections, } from "../db/automation-model-provider-auth"; import { SlackChannelStore } from "../db/slack-channel-store"; +import { SessionIndexStore } from "../db/session-index"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; +import { slackPostGate } from "../authorization/slack-post-gate"; import { IntegrationSettingsStore } from "../db/integration-settings"; import { buildSlackCompletionNotification, buildSlackSkipNotification, parseSlackTriggerMetadata, type SlackRunMetadata, - type SlackCompletionContext, } from "./slack-completion"; import { getUserAuth } from "../auth/user/runtime"; import { GitHubAttributionUnavailableError } from "../source-control/github-credential-authority"; @@ -93,7 +95,7 @@ import { } from "../automation/session-target"; import { isAutomationExecutionAuthorized } from "../automation/authorization-guard"; import type { RequestContext } from "../routes/shared"; -import { deliverWithRetry } from "../session/callback-delivery"; +import { retryDelivery } from "../session/callback-delivery"; import { AmbiguousGitHubIdentityError, resolveGitHubEnrichmentForCanonicalUser, @@ -1485,21 +1487,9 @@ export class Scheduler { // Slack-triggered runs post the agent's result into the triggering message's // thread and clear the `eyes` reaction when they finish. The scheduler owns // this fan-out (not the session callback path) because the message - // coordinates live on the invocation. Best-effort. - const invocation = await store.getInvocationById(run.invocation_id); - const slackMeta = parseSlackTriggerMetadata(invocation?.trigger_metadata ?? null); - if (slackMeta) { - const automation = await store.getById(body.automationId); - await this.notifySlackCompletion(run, slackMeta, { - sessionId: body.sessionId, - messageId: body.messageId, - success: body.success, - error: body.error, - repoFullName: formatRunRepositoryLabel(run), - model: automation?.model ?? "", - reasoningEffort: automation?.reasoning_effort ?? undefined, - }); - } + // coordinates live on the invocation. Only the terminal CAS winner owns + // publication retries; retrying runComplete itself would be ignored. + await this.notifySlackCompletion(store, run, body); } /** @@ -1511,28 +1501,69 @@ export class Scheduler { * `SLACK_BOT` is unbound, or when the secret is unset — all best-effort. */ private async notifySlackCompletion( + store: AutomationStore, run: AutomationRunRow, - meta: SlackRunMetadata, - ctx: SlackCompletionContext + completion: AutomationRunCompletion ): Promise { const binding = this.env.SLACK_BOT; const secret = callbackSigningSecret(this.env, "slack-bot"); if (!binding || !secret) return; - const body = buildSlackCompletionNotification(meta, ctx); - if (!body) return; + await retryDelivery( + async (signal) => { + const invocation = await store.getInvocationById(run.invocation_id); + const meta = parseSlackTriggerMetadata(invocation?.trigger_metadata ?? null); + if (!meta?.messageTs) return { outcome: "delivered", value: undefined }; + const automation = await store.getById(completion.automationId); + const [session, channelBinding] = await Promise.all([ + new SessionIndexStore(this.db).get(completion.sessionId), + new TeamChannelBindingStore(this.db).get("slack", meta.channel), + ]); + // D1 reads cannot be canceled; an expired attempt must not reach the wire. + signal.throwIfAborted(); + const denial = slackPostGate(session, channelBinding); + const body = denial + ? { + kind: "slack.thread_closed", + sessionId: completion.sessionId, + timestamp: Date.now(), + context: { channel: meta.channel, threadTs: meta.messageTs }, + } + : buildSlackCompletionNotification(meta, { + sessionId: completion.sessionId, + messageId: completion.messageId, + success: completion.success, + error: completion.error, + repoFullName: formatRunRepositoryLabel(run), + model: automation?.model ?? "", + reasoningEffort: automation?.reasoning_effort ?? undefined, + }); + if (!body) return { outcome: "delivered", value: undefined }; + + if (denial) { + this.log.info("Slack completion denied by session scope", { + event: "scheduler.slack_complete_denied", + run_id: run.id, + session_id: completion.sessionId, + reason: denial, + }); + } - const signature = await computeHmacHex(JSON.stringify(body), secret); - await deliverWithRetry( - (signal) => - binding.fetch("https://internal/callbacks/automation-complete", { + const signature = await computeHmacHex(JSON.stringify(body), secret); + signal.throwIfAborted(); + const endpoint = denial ? "thread_closed" : "automation-complete"; + const response = await binding.fetch(`https://internal/callbacks/${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, signature }), signal, - }), + }); + return response.ok + ? { outcome: "delivered", value: undefined } + : { outcome: "retryable_failure", failure: response }; + }, (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - ({ attempt, response, error }) => { + ({ attempt, failure: response, error }) => { this.log.warn("Slack completion callback failed", { event: "scheduler.slack_complete_failed", automation_id: run.automation_id, diff --git a/packages/control-plane/src/session/callback-notification-service.test.ts b/packages/control-plane/src/session/callback-notification-service.test.ts index f80cc88399..74a58eb983 100644 --- a/packages/control-plane/src/session/callback-notification-service.test.ts +++ b/packages/control-plane/src/session/callback-notification-service.test.ts @@ -9,6 +9,7 @@ import { } from "./callback-notification-service"; import type { MessageRepository } from "./message-repository"; import type { FetchClient } from "../platform-ports"; +import type { SlackPostScope } from "../authorization/slack-post-gate"; import { verifyCallbackSignature } from "@open-inspect/shared/auth"; import { linearCompletionCallbackSchema, @@ -25,6 +26,13 @@ const LINEAR_CALLBACK_CONTEXT = { model: "anthropic/claude-haiku-4-5", }; +const SLACK_CALLBACK_CONTEXT = { + channel: "C123", + threadTs: "1234.5678", + repoFullName: "secret/repository", + model: "private-model", +}; + // ---- Mock factories ---- function createMockLogger(): Logger { @@ -62,6 +70,13 @@ function createTestHarness(overrides?: { const slackBot = createMockFetcher(); const linearBot = createMockFetcher(); const sleep = vi.fn(async () => {}); + const slackPostScope = { + getSession: vi.fn().mockResolvedValue({ + ownerTeamId: "team-a", + visibility: "workspace", + }), + getChannelBinding: vi.fn().mockResolvedValue(null), + }; const env: CallbackServiceEnv = { SERVICE_AUTH_SECRET_SLACK_BOT: "test-secret", @@ -74,6 +89,7 @@ function createTestHarness(overrides?: { const deps: CallbackServiceDeps = { repository: repository as CallbackRepository, messageRepository: repository as unknown as MessageRepository, + slackPostScope, env, log, getSessionId: overrides?.getSessionId ?? (() => "session-123"), @@ -89,6 +105,7 @@ function createTestHarness(overrides?: { slackBot, linearBot, sleep, + slackPostScope, }; } @@ -101,6 +118,39 @@ describe("CallbackNotificationService", () => { harness = createTestHarness(); }); + describe.each(["tool_call", "activity"] as const)("Slack post gate: %s", (path) => { + it.each(["getSession", "getChannelBinding"] as const)( + "fails closed when %s fails", + async (lookup) => { + harness.repository.getMessageCallbackContext.mockReturnValue({ + callback_context: JSON.stringify(SLACK_CALLBACK_CONTEXT), + source: "slack", + }); + harness.repository.getProcessingMessageWithStartedAt.mockReturnValue({ + id: "msg-1", + started_at: 1, + }); + harness.slackBot.fetch.mockResolvedValue(new Response("ok")); + harness.slackPostScope[lookup].mockRejectedValue(new Error("D1 unavailable")); + if (path === "tool_call") { + await harness.service.notifyToolCall("msg-1", { + type: "tool_call", + tool: "bash", + args: { command: "secret command" }, + callId: "call-1", + }); + } else { + await harness.service.refreshSlackActivity("msg-1", Date.now()); + } + expect(harness.repository.getSession).not.toHaveBeenCalled(); + expect(harness.slackPostScope.getSession).toHaveBeenCalledWith("session-123"); + expect(harness.slackPostScope.getChannelBinding).toHaveBeenCalledWith("C123"); + expect(harness.slackBot.fetch).not.toHaveBeenCalled(); + expect(harness.linearBot.fetch).not.toHaveBeenCalled(); + } + ); + }); + describe("notifyComplete", () => { it("skips when no callback context", async () => { vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue(null); @@ -185,6 +235,8 @@ describe("CallbackNotificationService", () => { await h.service.notifyComplete("msg-1", true); expect(h.slackBot.fetch).not.toHaveBeenCalled(); + expect(h.slackPostScope.getSession).not.toHaveBeenCalled(); + expect(h.sleep).not.toHaveBeenCalled(); }); it("skips when no binding for source", async () => { @@ -209,75 +261,277 @@ describe("CallbackNotificationService", () => { duration_ms: expect.any(Number), }) ); + expect(h.slackPostScope.getSession).not.toHaveBeenCalled(); + expect(h.sleep).not.toHaveBeenCalled(); }); - it("calls binding with signed payload on success", async () => { - vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ - callback_context: JSON.stringify({ channel: "C123", threadTs: "1234.5678" }), - source: "slack", - }); + it.each([null, { teamId: "team-a" }])( + "calls binding with signed payload on success: %j", + async (binding) => { + harness.slackPostScope.getChannelBinding.mockResolvedValue(binding); + vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ + callback_context: JSON.stringify({ channel: "C123", threadTs: "1234.5678" }), + source: "slack", + }); - const mockResponse = new Response("ok", { status: 200 }); - vi.mocked(harness.slackBot.fetch).mockResolvedValue(mockResponse); + const mockResponse = new Response("ok", { status: 200 }); + vi.mocked(harness.slackBot.fetch).mockResolvedValue(mockResponse); - await harness.service.notifyComplete("msg-1", true); + await harness.service.notifyComplete("msg-1", true); - const fetchMock = harness.slackBot.fetch; - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://internal/callbacks/complete", - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }) - ); + const fetchMock = harness.slackBot.fetch; + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://internal/callbacks/complete", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + ); - // Verify payload shape - const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); - expect(body).toMatchObject({ - sessionId: "session-123", - messageId: "msg-1", - success: true, - context: { channel: "C123", threadTs: "1234.5678" }, - }); - expect(body.signature).toEqual(expect.any(String)); - expect(body.timestamp).toEqual(expect.any(Number)); + // Verify payload shape + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(body).toMatchObject({ + sessionId: "session-123", + messageId: "msg-1", + success: true, + context: { channel: "C123", threadTs: "1234.5678" }, + }); + expect(body.signature).toEqual(expect.any(String)); + expect(body.timestamp).toEqual(expect.any(Number)); + + const terminalEvents = vi + .mocked(harness.log.info) + .mock.calls.filter(([event]) => event === "callback.complete_delivery"); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0][1]).toEqual( + expect.objectContaining({ + session_id: "session-123", + message_id: "msg-1", + source: "slack", + outcome: "success", + duration_ms: expect.any(Number), + attempts: 1, + retries: 0, + http_status: 200, + }) + ); + } + ); - const terminalEvents = vi - .mocked(harness.log.info) - .mock.calls.filter(([event]) => event === "callback.complete_delivery"); - expect(terminalEvents).toHaveLength(1); - expect(terminalEvents[0][1]).toEqual( - expect.objectContaining({ - session_id: "session-123", - message_id: "msg-1", + it.each(["fetch", "getSession", "getChannelBinding"] as const)( + "retries once on %s failure", + async (failure) => { + vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ + callback_context: JSON.stringify(SLACK_CALLBACK_CONTEXT), source: "slack", - outcome: "success", - duration_ms: expect.any(Number), - attempts: 1, - retries: 0, - http_status: 200, - }) + }); + + const fetchMock = vi.mocked(harness.slackBot.fetch); + fetchMock.mockResolvedValue(new Response("ok")); + (failure === "fetch" ? fetchMock : harness.slackPostScope[failure]).mockRejectedValueOnce( + new Error("delivery unavailable") + ); + + await harness.service.notifyComplete("msg-1", true); + + expect(fetchMock).toHaveBeenCalledTimes(failure === "fetch" ? 2 : 1); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://internal/callbacks/complete", + expect.anything() + ); + const body = JSON.parse(String(fetchMock.mock.calls.at(-1)?.[1]?.body)); + expect(body).toMatchObject({ + messageId: "msg-1", + success: true, + context: SLACK_CALLBACK_CONTEXT, + }); + expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + expect(harness.slackPostScope.getSession).toHaveBeenCalledTimes(2); + expect(harness.slackPostScope.getChannelBinding).toHaveBeenCalledTimes(2); + expect(harness.sleep).toHaveBeenCalledExactlyOnceWith(1000); + expect(harness.log.info).toHaveBeenCalledWith( + "callback.complete_delivery", + expect.objectContaining({ + session_id: "session-123", + message_id: "msg-1", + outcome: "success", + attempts: 2, + retries: 1, + }) + ); + } + ); + + describe("Slack publication preparation retries", () => { + beforeEach(() => { + harness.repository.getMessageCallbackContext.mockReturnValue({ + callback_context: JSON.stringify(SLACK_CALLBACK_CONTEXT), + source: "slack", + }); + harness.slackBot.fetch.mockResolvedValue(new Response("ok")); + }); + + it.each([ + { visibility: "private", binding: null, reason: "private_session" }, + { + visibility: "workspace", + binding: { teamId: "team-b" }, + reason: "channel_team_mismatch", + }, + ] as const)( + "selects only closure on recovered $reason without nested transport retries", + async ({ visibility, binding, reason }) => { + harness.slackPostScope.getSession + .mockRejectedValueOnce(new Error("D1 unavailable")) + .mockResolvedValue({ ownerTeamId: "team-a", visibility }); + harness.slackPostScope.getChannelBinding.mockResolvedValue(binding); + harness.slackBot.fetch.mockResolvedValue(new Response("unavailable", { status: 503 })); + + await harness.service.notifyComplete("msg-1", false, "secret error"); + + expect(harness.slackPostScope.getSession).toHaveBeenCalledTimes(2); + expect(harness.sleep).toHaveBeenCalledExactlyOnceWith(1000); + expect(harness.slackBot.fetch).toHaveBeenCalledOnce(); + expect(harness.slackBot.fetch.mock.calls[0][0]).toBe( + "https://internal/callbacks/thread_closed" + ); + const body = JSON.parse(String(harness.slackBot.fetch.mock.calls[0][1]?.body)); + expect(body).toEqual({ + kind: "slack.thread_closed", + sessionId: "session-123", + timestamp: expect.any(Number), + context: { channel: "C123", threadTs: "1234.5678" }, + signature: expect.any(String), + }); + expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + expect(harness.log.info).toHaveBeenCalledWith( + "callback.complete_delivery", + expect.objectContaining({ + outcome: "rejected", + reject_reason: reason, + attempts: 2, + retries: 1, + http_status: 503, + }) + ); + } ); - }); - it("retries once on fetch failure", async () => { - vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ - callback_context: JSON.stringify({ channel: "C123" }), - source: "slack", + it("rechecks scope after transport failure and replaces completion with closure", async () => { + harness.slackPostScope.getChannelBinding + .mockResolvedValueOnce(null) + .mockResolvedValue({ teamId: "team-b" }); + harness.slackBot.fetch + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce(new Response("ok")); + + await harness.service.notifyComplete("msg-1", false, "secret error"); + + expect(harness.slackBot.fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://internal/callbacks/complete", + "https://internal/callbacks/thread_closed", + ]); + expect(harness.slackPostScope.getSession).toHaveBeenCalledTimes(2); + const closure = JSON.parse(String(harness.slackBot.fetch.mock.calls[1][1]?.body)); + expect(closure.context).toEqual({ channel: "C123", threadTs: "1234.5678" }); + expect(closure).not.toHaveProperty("error"); + expect(closure).not.toHaveProperty("messageId"); }); - const fetchMock = vi.mocked(harness.slackBot.fetch); - fetchMock - .mockRejectedValueOnce(new Error("network error")) - .mockResolvedValueOnce(new Response("ok", { status: 200 })); + it("does not retain a denied outcome when the retry's current scope allows completion", async () => { + harness.slackPostScope.getSession + .mockResolvedValueOnce({ ownerTeamId: "team-a", visibility: "private" }) + .mockResolvedValue({ ownerTeamId: "team-a", visibility: "workspace" }); + harness.slackBot.fetch + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce(new Response("ok")); + + await harness.service.notifyComplete("msg-1", true); + + expect(harness.slackBot.fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://internal/callbacks/thread_closed", + "https://internal/callbacks/complete", + ]); + const terminalEvent = vi.mocked(harness.log.info).mock.calls.at(-1)?.[1]; + expect(terminalEvent).toMatchObject({ outcome: "success", attempts: 2, retries: 1 }); + expect(terminalEvent).not.toHaveProperty("reject_reason"); + }); - await harness.service.notifyComplete("msg-1", true); + it.each(["getSession", "getChannelBinding"] as const)( + "exhausts %s reads without sending content or closure", + async (lookup) => { + harness.slackPostScope[lookup].mockRejectedValue(new Error("D1 unavailable")); + + await expect(harness.service.notifyComplete("msg-1", true)).resolves.toBeUndefined(); + + expect(harness.slackPostScope.getSession).toHaveBeenCalledTimes(2); + expect(harness.slackPostScope.getChannelBinding).toHaveBeenCalledTimes(2); + expect(harness.slackBot.fetch).not.toHaveBeenCalled(); + expect(harness.sleep).toHaveBeenCalledExactlyOnceWith(1000); + expect(harness.log.error).toHaveBeenCalledWith( + "callback.complete_delivery", + expect.objectContaining({ outcome: "error", attempts: 2, retries: 1 }) + ); + } + ); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(harness.log.info).toHaveBeenCalledWith( - "callback.complete_delivery", - expect.objectContaining({ message_id: "msg-1", attempts: 2, retries: 1 }) + it.each(["workspace", "private"] as const)( + "does not transport a timed-out %s attempt after its uncancelable scope read finishes", + async (visibility) => { + vi.useFakeTimers(); + try { + let release!: (session: Awaited>) => void; + const pendingRead = new Promise>>( + (resolve) => { + release = resolve; + } + ); + harness.slackPostScope.getSession.mockReturnValueOnce(pendingRead); + const completion = harness.service.notifyComplete("msg-1", true); + + await vi.advanceTimersByTimeAsync(10_000); + release({ ownerTeamId: "team-a", visibility }); + await completion; + + expect(harness.slackPostScope.getSession).toHaveBeenCalledTimes(2); + expect(harness.slackBot.fetch).toHaveBeenCalledOnce(); + expect(harness.slackBot.fetch.mock.calls[0][1]?.signal?.aborted).toBe(false); + expect(harness.sleep).toHaveBeenCalledExactlyOnceWith(1000); + } finally { + vi.useRealTimers(); + } + } + ); + + it.each([false, true])( + "keeps invalid closure coordinates a rejected no-op after a transient read: %s", + async (retryRead) => { + harness.repository.getMessageCallbackContext.mockReturnValue({ + callback_context: JSON.stringify({ channel: "C123" }), + source: "slack", + }); + harness.slackPostScope.getSession.mockResolvedValue({ + ownerTeamId: "team-a", + visibility: "private", + }); + if (retryRead) { + harness.slackPostScope.getSession.mockRejectedValueOnce(new Error("D1 unavailable")); + } + + await harness.service.notifyComplete("msg-1", true); + + expect(harness.slackBot.fetch).not.toHaveBeenCalled(); + expect(harness.sleep).toHaveBeenCalledTimes(retryRead ? 1 : 0); + expect(harness.log.info).toHaveBeenCalledWith( + "callback.complete_delivery", + expect.objectContaining({ + outcome: "rejected", + reject_reason: "private_session", + attempts: retryRead ? 2 : 0, + }) + ); + } ); }); @@ -355,6 +609,45 @@ describe("CallbackNotificationService", () => { expect(linearCompletionCallbackSchema.safeParse(body).success).toBe(true); expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); }); + + it("preserves signed Linear completion retries without Slack authority reads", async () => { + harness.repository.getMessageCallbackContext.mockReturnValue({ + callback_context: JSON.stringify(LINEAR_CALLBACK_CONTEXT), + source: "linear", + }); + harness.linearBot.fetch + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce(new Response("ok")); + + await harness.service.notifyComplete("msg-1", true); + + expect(harness.linearBot.fetch).toHaveBeenCalledTimes(2); + expect(harness.slackPostScope.getSession).not.toHaveBeenCalled(); + expect(harness.slackPostScope.getChannelBinding).not.toHaveBeenCalled(); + expect(harness.sleep).toHaveBeenCalledExactlyOnceWith(1000); + for (const [, init] of harness.linearBot.fetch.mock.calls) { + const body = JSON.parse(String(init?.body)); + expect(linearCompletionCallbackSchema.safeParse(body).success).toBe(true); + expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + } + }); + + it("rejects invalid Linear completion payloads without retrying", async () => { + harness.repository.getMessageCallbackContext.mockReturnValue({ + callback_context: JSON.stringify({ source: "linear" }), + source: "linear", + }); + + await harness.service.notifyComplete("msg-1", true); + + expect(harness.linearBot.fetch).not.toHaveBeenCalled(); + expect(harness.sleep).not.toHaveBeenCalled(); + expect(harness.slackPostScope.getSession).not.toHaveBeenCalled(); + expect(harness.log.info).toHaveBeenCalledWith( + "callback.complete_delivery", + expect.objectContaining({ reject_reason: "invalid_payload", attempts: 0 }) + ); + }); }); describe("notifyStarted", () => { @@ -535,28 +828,48 @@ describe("CallbackNotificationService", () => { return fetchMock; } - it("posts a signed refresh for a slack message", async () => { - const fetchMock = withSlackMessage(); + it.each([null, { teamId: "team-a" }, { teamId: "team-b" }])( + "posts a signed refresh or safe closure for a slack message: %j", + async (binding) => { + const denied = binding?.teamId === "team-b"; + harness.slackPostScope.getChannelBinding.mockResolvedValue(binding); + const fetchMock = withSlackMessage(); - await harness.service.refreshSlackActivity("msg-1", NOW); + await harness.service.refreshSlackActivity("msg-1", NOW); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://internal/callbacks/activity", - expect.objectContaining({ method: "POST" }) - ); - const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); - expect(body).toMatchObject({ - kind: SLACK_ACTIVITY_REFRESH_KIND, - sessionId: "session-123", - messageId: "msg-1", - timestamp: NOW, - }); - // The route re-parses the context it is handed, so the producer must - // already satisfy the canonical contract. - expect(slackCallbackContextSchema.safeParse(body.context).success).toBe(true); - expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `https://internal/callbacks/${denied ? "thread_closed" : "activity"}`, + expect.objectContaining({ method: "POST" }) + ); + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + if (denied) { + expect(body).toEqual({ + kind: "slack.thread_closed", + sessionId: "session-123", + timestamp: expect.any(Number), + context: { channel: "C123", threadTs: "111.222" }, + signature: expect.any(String), + }); + expect(harness.log.info).toHaveBeenCalledWith( + "callback.activity_refresh", + expect.objectContaining({ outcome: "rejected", reject_reason: "channel_team_mismatch" }) + ); + } else { + expect(body).toMatchObject({ + kind: SLACK_ACTIVITY_REFRESH_KIND, + sessionId: "session-123", + messageId: "msg-1", + timestamp: NOW, + }); + // The route re-parses the context it is handed, so the producer must + // already satisfy the canonical contract. + expect(slackCallbackContextSchema.safeParse(body.context).success).toBe(true); + } + expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + expect(harness.linearBot.fetch).not.toHaveBeenCalled(); + } + ); it("does not refresh a message that is no longer processing", async () => { const fetchMock = withSlackMessage(null); @@ -574,19 +887,6 @@ describe("CallbackNotificationService", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("abandons a refresh whose message terminates before it reaches the wire", async () => { - const fetchMock = withSlackMessage(); - // The turn completes between the heartbeat that asked for this refresh - // and the moment the refresh is ready to send. - vi.mocked(harness.repository.getProcessingMessageWithStartedAt).mockImplementation( - () => null - ); - - await harness.service.refreshSlackActivity("msg-1", NOW); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - it("holds the next refresh until the interval has passed", async () => { const fetchMock = withSlackMessage(); @@ -625,16 +925,6 @@ describe("CallbackNotificationService", () => { expect(rebuilt.slackBot.fetch).toHaveBeenCalledTimes(1); }); - it("does not read the message while the window is still open", async () => { - withSlackMessage(); - - await harness.service.refreshSlackActivity("msg-1", NOW); - harness.repository.getMessageCallbackContext.mockClear(); - - await harness.service.refreshSlackActivity("msg-1", NOW + 30_000); - expect(harness.repository.getMessageCallbackContext).not.toHaveBeenCalled(); - }); - it("sends one bounded attempt and leaves the window open when it fails", async () => { const fetchMock = withSlackMessage(); fetchMock.mockResolvedValue(new Response("nope", { status: 500 })); @@ -667,6 +957,8 @@ describe("CallbackNotificationService", () => { expect(harness.slackBot.fetch).not.toHaveBeenCalled(); expect(harness.linearBot.fetch).not.toHaveBeenCalled(); + expect(harness.slackPostScope.getSession).not.toHaveBeenCalled(); + expect(harness.slackPostScope.getChannelBinding).not.toHaveBeenCalled(); }); it("skips a slack message that carries no callback context", async () => { @@ -756,22 +1048,73 @@ describe("CallbackNotificationService", () => { }); describe("notifyToolCall", () => { - it("skips when throttled (< 3s since last call)", async () => { - vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ - callback_context: JSON.stringify({ channel: "C123" }), - source: "slack", - }); + it.each([null, { teamId: "team-a" }])( + "skips when throttled (< 3s since last call): %j", + async (binding) => { + harness.slackPostScope.getChannelBinding.mockResolvedValue(binding); + vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ + callback_context: JSON.stringify({ channel: "C123" }), + source: "slack", + }); - const fetchMock = vi.mocked(harness.slackBot.fetch); - fetchMock.mockResolvedValue(new Response("ok", { status: 200 })); + const fetchMock = vi.mocked(harness.slackBot.fetch); + fetchMock.mockResolvedValue(new Response("ok", { status: 200 })); - // First call should go through - await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "bash" }); - expect(fetchMock).toHaveBeenCalledTimes(1); + // First call should go through + await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "bash" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://internal/callbacks/tool_call", + expect.anything() + ); + + // Second call within 3s should be throttled + await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "read" }); + expect(fetchMock).toHaveBeenCalledTimes(1); // still 1 + expect(harness.slackPostScope.getSession).toHaveBeenCalledOnce(); + expect(harness.slackPostScope.getChannelBinding).toHaveBeenCalledOnce(); + } + ); - // Second call within 3s should be throttled - await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "read" }); - expect(fetchMock).toHaveBeenCalledTimes(1); // still 1 + it("throttles denied tool events without repeated reads or closure callbacks", async () => { + vi.useFakeTimers(); + try { + const now = 1_700_000_000_000; + vi.setSystemTime(now); + harness.repository.getMessageCallbackContext.mockReturnValue({ + callback_context: JSON.stringify({ channel: "C123", threadTs: "111.222" }), + source: "slack", + }); + harness.slackPostScope.getSession.mockResolvedValue({ + ownerTeamId: "team-a", + visibility: "private", + }); + harness.slackBot.fetch.mockResolvedValue(new Response("ok")); + await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "bash" }); + expect(harness.slackBot.fetch).toHaveBeenLastCalledWith( + "https://internal/callbacks/thread_closed", + expect.anything() + ); + vi.setSystemTime(now + 1000); + await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "read" }); + expect(harness.slackPostScope.getSession).toHaveBeenCalledOnce(); + expect(harness.slackPostScope.getChannelBinding).toHaveBeenCalledOnce(); + expect(harness.slackBot.fetch).toHaveBeenCalledOnce(); + harness.slackPostScope.getSession.mockResolvedValue({ + ownerTeamId: "team-a", + visibility: "workspace", + }); + vi.setSystemTime(now + 3000); + await harness.service.notifyToolCall("msg-1", { type: "tool_call", tool: "read" }); + expect(harness.slackPostScope.getSession).toHaveBeenCalledTimes(2); + expect(harness.slackBot.fetch).toHaveBeenCalledTimes(2); + expect(harness.slackBot.fetch).toHaveBeenLastCalledWith( + "https://internal/callbacks/tool_call", + expect.anything() + ); + } finally { + vi.useRealTimers(); + } }); it("fires callback on first call", async () => { @@ -809,6 +1152,8 @@ describe("CallbackNotificationService", () => { expect(body.signature).toEqual(expect.any(String)); expect(linearToolCallCallbackSchema.safeParse(body).success).toBe(true); expect(await verifyCallbackSignature(body, "test-secret")).toBe(true); + expect(harness.slackPostScope.getSession).not.toHaveBeenCalled(); + expect(harness.slackPostScope.getChannelBinding).not.toHaveBeenCalled(); }); it("skips Linear callbacks whose tool arguments are missing", async () => { diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index d62f965601..039ee39ba0 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -15,6 +15,7 @@ import { SLACK_ACTIVITY_REFRESH_KIND, } from "@open-inspect/shared/types/session-api"; import { callbackSigningSecret, type CallbackDestination } from "../auth/service/callback-signing"; +import { slackPostGate, type SlackPostScope } from "../authorization/slack-post-gate"; import type { Logger } from "../logger"; import { deliverWithRetry, retryDelivery } from "./callback-delivery"; import { notifyLinearStarted } from "./linear-start-callback"; @@ -51,6 +52,7 @@ export type AutomationRunCompletionHandler = (completion: AutomationRunCompletio export interface CallbackServiceDeps { repository: CallbackRepository; messageRepository: MessageRepository; + slackPostScope: SlackPostScope; env: CallbackServiceEnv; log: Logger; getSessionId: () => string; @@ -71,6 +73,19 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function hasSlackThreadCoordinates(context: unknown): context is { + channel: string; + threadTs: string; +} { + return ( + isRecord(context) && + typeof context.channel === "string" && + !!context.channel && + typeof context.threadTs === "string" && + !!context.threadTs + ); +} + /** * How stale a Slack assistant-thread activity indicator may get before the * next sandbox heartbeat refreshes it. @@ -106,6 +121,7 @@ interface CallbackDeliveryResult { export class CallbackNotificationService { private readonly repository: CallbackRepository; private readonly messageRepository: MessageRepository; + private readonly slackPostScope: SlackPostScope; private readonly env: CallbackServiceEnv; private readonly log: Logger; private readonly getSessionId: () => string; @@ -124,6 +140,7 @@ export class CallbackNotificationService { constructor(deps: CallbackServiceDeps) { this.repository = deps.repository; this.messageRepository = deps.messageRepository; + this.slackPostScope = deps.slackPostScope; this.env = deps.env; this.log = deps.log; this.getSessionId = deps.getSessionId; @@ -146,6 +163,64 @@ export class CallbackNotificationService { return computeHmacHex(JSON.stringify(data), secret); } + private async slackPostDenial(sessionId: string, context: unknown): Promise { + if (!isRecord(context) || typeof context.channel !== "string" || !context.channel) { + return "invalid_callback_context"; + } + const [session, binding] = await Promise.all([ + this.slackPostScope.getSession(sessionId), + this.slackPostScope.getChannelBinding(context.channel), + ]); + return slackPostGate(session, binding); + } + + /** Closure carries coordinates only, never session content or tool arguments. */ + private async notifySlackThreadClosed( + sessionId: string, + context: unknown, + binding: FetchClient, + secret: string + ): Promise { + if (!hasSlackThreadCoordinates(context)) { + return { delivered: false, attempts: 0 }; + } + return deliverWithRetry( + (signal) => this.sendSlackThreadClosed(sessionId, context, binding, secret, signal), + this.sleep, + ({ attempt, response, error }) => { + this.log.warn("callback.thread_closed_delivery_attempt_failed", { + session_id: sessionId, + attempt, + ...(response ? { http_status: response.status } : {}), + ...(error !== undefined ? { error: error instanceof Error ? error : String(error) } : {}), + }); + } + ); + } + + private async sendSlackThreadClosed( + sessionId: string, + context: { channel: string; threadTs: string }, + binding: FetchClient, + secret: string, + signal: AbortSignal + ): Promise { + const unsigned = { + kind: "slack.thread_closed", + sessionId, + timestamp: Date.now(), + context: { channel: context.channel, threadTs: context.threadTs }, + }; + const signature = await this.signPayload(unsigned, secret); + signal.throwIfAborted(); + return binding.fetch("https://internal/callbacks/thread_closed", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...unsigned, signature }), + signal, + }); + } + /** * Where a non-automation callback goes and which key signs it — one * decision, so destination and signing key cannot diverge (the CP signs @@ -222,7 +297,8 @@ export class CallbackNotificationService { let thrownError: unknown; try { - sessionId = this.getSessionId(); + const callbackSessionId = this.getSessionId(); + sessionId = callbackSessionId; const message = this.messageRepository.getMessageCallbackContext(messageId); if (!message?.callback_context) { result.rejectReason = "no_callback_context"; @@ -279,30 +355,69 @@ export class CallbackNotificationService { return; } const payloadData = parsedCallback?.data ?? callbackData; - const signature = await this.signPayload(payloadData, secret); - const payload = { ...payloadData, signature }; - result = await deliverWithRetry( - (signal) => - binding.fetch("https://internal/callbacks/complete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal, - }), + let rejectReason: string | undefined; + const delivery = await retryDelivery( + async (signal) => { + rejectReason = undefined; + const denial = + source === "linear" ? null : await this.slackPostDenial(callbackSessionId, rawContext); + // D1 reads cannot be canceled; an expired attempt must not reach the wire. + signal.throwIfAborted(); + rejectReason = denial ?? undefined; + let response: Response; + if (denial) { + if (!hasSlackThreadCoordinates(rawContext)) { + return { outcome: "delivered", value: null }; + } + response = await this.sendSlackThreadClosed( + callbackSessionId, + rawContext, + binding, + secret, + signal + ); + } else { + const signature = await this.signPayload(payloadData, secret); + signal.throwIfAborted(); + response = await binding.fetch("https://internal/callbacks/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payloadData, signature }), + signal, + }); + } + return response.ok + ? { outcome: "delivered", value: response } + : { outcome: "retryable_failure", failure: response }; + }, this.sleep, - ({ attempt, response, error: deliveryError }) => { - this.log.warn("callback.complete_delivery_attempt_failed", { - message_id: messageId, - session_id: sessionId, - source, - attempt, - ...(response ? { http_status: response.status } : {}), - ...(deliveryError !== undefined - ? { error: deliveryError instanceof Error ? deliveryError : String(deliveryError) } - : {}), - }); + ({ attempt, failure: response, error: deliveryError }) => { + this.log.warn( + rejectReason + ? "callback.thread_closed_delivery_attempt_failed" + : "callback.complete_delivery_attempt_failed", + { + ...(!rejectReason ? { message_id: messageId, source } : {}), + session_id: sessionId, + attempt, + ...(response ? { http_status: response.status } : {}), + ...(deliveryError !== undefined + ? { error: deliveryError instanceof Error ? deliveryError : String(deliveryError) } + : {}), + } + ); } ); + const response = delivery.outcome === "delivered" ? delivery.value : delivery.failure; + result = { + delivered: delivery.outcome === "delivered" && delivery.value !== null, + attempts: + delivery.outcome === "delivered" && delivery.value === null && delivery.attempts === 1 + ? 0 + : delivery.attempts, + ...(response ? { httpStatus: response.status } : {}), + ...(rejectReason ? { rejectReason } : {}), + }; } catch (caught) { thrownError = caught; } finally { @@ -439,6 +554,27 @@ export class CallbackNotificationService { } const sessionId = this.getSessionId(); + try { + const denial = await this.slackPostDenial(sessionId, context); + if (denial) { + await this.notifySlackThreadClosed(sessionId, context, binding, secret); + this.log.info("callback.activity_refresh", { + message_id: messageId, + session_id: sessionId, + outcome: "rejected", + reject_reason: denial, + }); + return; + } + } catch (error) { + this.log.warn("callback.activity_refresh", { + message_id: messageId, + session_id: sessionId, + outcome: "error", + error: error instanceof Error ? error : new Error(String(error)), + }); + return; + } const callbackData = { kind: SLACK_ACTIVITY_REFRESH_KIND, sessionId, @@ -609,6 +745,30 @@ export class CallbackNotificationService { if (now - this._lastToolCallCallbackTs < 3000) return; this._lastToolCallCallbackTs = now; + if (source !== "linear") { + try { + const denial = await this.slackPostDenial(sessionId, rawContext); + if (denial) { + await this.notifySlackThreadClosed(sessionId, rawContext, binding, secret); + this.log.info("callback.tool_call", { + message_id: messageId, + session_id: sessionId, + outcome: "rejected", + reject_reason: denial, + }); + return; + } + } catch (error) { + this.log.warn("callback.tool_call", { + message_id: messageId, + session_id: sessionId, + outcome: "error", + error: error instanceof Error ? error : new Error(String(error)), + }); + return; + } + } + const payloadData = parsedPayload?.data ?? callbackData; const signature = await this.signPayload(payloadData, secret); const payload = { ...payloadData, signature }; diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index c58b8c94f9..58ad19dba8 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -57,6 +57,7 @@ import { UserStore } from "../db/user-store"; import { IntegrationSettingsStore, resolveSlackSettings } from "../db/integration-settings"; import { SessionIndexStore } from "../db/session-index"; import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamChannelBindingStore } from "../db/team-channel-bindings"; import { SessionCollaboratorStore } from "../db/session-collaborators"; import { parsePersistedSandboxSettings } from "../sandbox/settings"; import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; @@ -217,6 +218,7 @@ export interface SessionComponents { sandboxEventProcessor: SessionSandboxEventProcessor; pushService: SandboxPushService; sessionLifecycleHandler: SessionLifecycleHandler; + callbackService: CallbackNotificationService; } /** @@ -327,6 +329,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // Shared single instances/closures — every consumer below takes these // rather than re-deriving its own copy. const sessionIndexStore = new SessionIndexStore(db); + const teamChannelBindingStore = new TeamChannelBindingStore(db); const resolveCredentialScope = (sessionId: string) => resolveSessionCredentialScope(db, sessionId, () => readCachedInstallationRepositories(env)); const teamMembershipStore = new TeamMembershipStore(db); @@ -397,6 +400,10 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi const callbackService = new CallbackNotificationService({ repository: sessionCoreRepository, messageRepository, + slackPostScope: { + getSession: (sessionId) => sessionIndexStore.get(sessionId), + getChannelBinding: (channelId) => teamChannelBindingStore.get("slack", channelId), + }, env, completeAutomationRun: (completion) => scheduler.runComplete(completion), log, @@ -1035,6 +1042,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sandboxEventProcessor, pushService, sessionLifecycleHandler, + callbackService, }; return { diff --git a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap index cc4cdfc8be..31f30df93b 100644 --- a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap @@ -10,97 +10,102 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"POST /api/auth/sign-out","pathname":"/api/auth/sign-out","groups":{},"authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /api/auth/error","pathname":"/api/auth/error","groups":{},"authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /internal/auth/sign-in-providers","pathname":"/internal/auth/sign-in-providers","groups":{},"authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/channel-bindings","pathname":"/teams/fixture-8-id%2Fraw/channel-bindings","groups":{"id":"fixture-8-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageBindings"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/channel-bindings/:provider/:externalId","pathname":"/teams/fixture-9-id%2Fraw/channel-bindings/fixture-9-provider%2Fraw/fixture-9-externalId%2Fraw","groups":{"id":"fixture-9-id%2Fraw","provider":"fixture-9-provider%2Fraw","externalId":"fixture-9-externalId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageBindings"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/channel-bindings/:provider/:externalId","pathname":"/teams/fixture-10-id%2Fraw/channel-bindings/fixture-10-provider%2Fraw/fixture-10-externalId%2Fraw","groups":{"id":"fixture-10-id%2Fraw","provider":"fixture-10-provider%2Fraw","externalId":"fixture-10-externalId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageBindings"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/slack-channels","pathname":"/teams/fixture-11-id%2Fraw/slack-channels","groups":{"id":"fixture-11-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageBindings"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /me/teams","pathname":"/me/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /teams","pathname":"/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams","pathname":"/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id","pathname":"/teams/fixture-11-id%2Fraw","groups":{"id":"fixture-11-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PATCH /teams/:id","pathname":"/teams/fixture-12-id%2Fraw","groups":{"id":"fixture-12-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canEditMetadata"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /teams/:id/archive","pathname":"/teams/fixture-13-id%2Fraw/archive","groups":{"id":"fixture-13-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canArchive"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /teams/:id/restore","pathname":"/teams/fixture-14-id%2Fraw/restore","groups":{"id":"fixture-14-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canArchive"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/members","pathname":"/teams/fixture-15-id%2Fraw/members","groups":{"id":"fixture-15-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /teams/:id/members/:userId","pathname":"/teams/fixture-16-id%2Fraw/members/fixture-16-userId%2Fraw","groups":{"id":"fixture-16-id%2Fraw","userId":"fixture-16-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageMembers"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /teams/:id/members/:userId","pathname":"/teams/fixture-17-id%2Fraw/members/fixture-17-userId%2Fraw","groups":{"id":"fixture-17-id%2Fraw","userId":"fixture-17-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"removeMember","targetUserIdParam":"userId"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /teams/:id/join","pathname":"/teams/fixture-18-id%2Fraw/join","groups":{"id":"fixture-18-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canJoin"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/sessions","pathname":"/teams/fixture-19-id%2Fraw/sessions","groups":{"id":"fixture-19-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"},{"kind":"permission","permission":"sessions.read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/repository-grants","pathname":"/teams/fixture-20-id%2Fraw/repository-grants","groups":{"id":"fixture-20-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /teams/:id/repository-grants","pathname":"/teams/fixture-21-id%2Fraw/repository-grants","groups":{"id":"fixture-21-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /teams/:id/repository-grants/:grantId","pathname":"/teams/fixture-22-id%2Fraw/repository-grants/fixture-22-grantId%2Fraw","groups":{"id":"fixture-22-id%2Fraw","grantId":"fixture-22-grantId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id","pathname":"/teams/fixture-15-id%2Fraw","groups":{"id":"fixture-15-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PATCH /teams/:id","pathname":"/teams/fixture-16-id%2Fraw","groups":{"id":"fixture-16-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canEditMetadata"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /teams/:id/archive","pathname":"/teams/fixture-17-id%2Fraw/archive","groups":{"id":"fixture-17-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canArchive"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /teams/:id/restore","pathname":"/teams/fixture-18-id%2Fraw/restore","groups":{"id":"fixture-18-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canArchive"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/members","pathname":"/teams/fixture-19-id%2Fraw/members","groups":{"id":"fixture-19-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/members/:userId","pathname":"/teams/fixture-20-id%2Fraw/members/fixture-20-userId%2Fraw","groups":{"id":"fixture-20-id%2Fraw","userId":"fixture-20-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageMembers"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/members/:userId","pathname":"/teams/fixture-21-id%2Fraw/members/fixture-21-userId%2Fraw","groups":{"id":"fixture-21-id%2Fraw","userId":"fixture-21-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"removeMember","targetUserIdParam":"userId"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /teams/:id/join","pathname":"/teams/fixture-22-id%2Fraw/join","groups":{"id":"fixture-22-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canJoin"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/sessions","pathname":"/teams/fixture-23-id%2Fraw/sessions","groups":{"id":"fixture-23-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"},{"kind":"permission","permission":"sessions.read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/repository-grants","pathname":"/teams/fixture-24-id%2Fraw/repository-grants","groups":{"id":"fixture-24-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/repository-grants","pathname":"/teams/fixture-25-id%2Fraw/repository-grants","groups":{"id":"fixture-25-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/repository-grants/:grantId","pathname":"/teams/fixture-26-id%2Fraw/repository-grants/fixture-26-grantId%2Fraw","groups":{"id":"fixture-26-id%2Fraw","grantId":"fixture-26-grantId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /settings/teams","pathname":"/settings/teams","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PATCH /settings/teams","pathname":"/settings/teams","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /channel-bindings/:provider/:externalId","pathname":"/channel-bindings/fixture-29-provider%2Fraw/fixture-29-externalId%2Fraw","groups":{"provider":"fixture-29-provider%2Fraw","externalId":"fixture-29-externalId%2Fraw"},"authentication":"service","authorization":{"kind":"service","services":["slack-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":true}", - "{"identity":"PUT /sessions/:id/visibility","pathname":"/sessions/fixture-26-id%2Fraw/visibility","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-27-id%2Fraw/collaborators/fixture-27-userId%2Fraw","groups":{"id":"fixture-27-id%2Fraw","userId":"fixture-27-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-28-id%2Fraw/collaborators/fixture-28-userId%2Fraw","groups":{"id":"fixture-28-id%2Fraw","userId":"fixture-28-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-29-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/visibility","pathname":"/sessions/fixture-31-id%2Fraw/visibility","groups":{"id":"fixture-31-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-32-id%2Fraw/collaborators/fixture-32-userId%2Fraw","groups":{"id":"fixture-32-id%2Fraw","userId":"fixture-32-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-33-id%2Fraw/collaborators/fixture-33-userId%2Fraw","groups":{"id":"fixture-33-id%2Fraw","userId":"fixture-33-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-34-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-34-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/inbox","pathname":"/sessions/inbox","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-32-id%2Fraw/read-state","groups":{"id":"fixture-32-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-33-id%2Fraw","groups":{"id":"fixture-33-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-37-id%2Fraw/read-state","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-38-id%2Fraw","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/export","pathname":"/sessions/export","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.export"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-35-id%2Fraw/export","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-36-id%2Fraw/sandbox-access","groups":{"id":"fixture-36-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-37-id%2Fraw","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-38-id%2Fraw/stop","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-39-id%2Fraw/sandbox-error","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-40-id%2Fraw/events","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-41-id%2Fraw/artifacts","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-42-id%2Fraw/participants","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-43-id%2Fraw/participant-profiles","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-44-id%2Fraw/messages","groups":{"id":"fixture-44-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-45-id%2Fraw/pr","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-46-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-47-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-48-id%2Fraw/scm-credentials","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-49-id%2Fraw/tunnel-urls","groups":{"id":"fixture-49-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-50-id%2Fraw/title","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-51-id%2Fraw/archive","groups":{"id":"fixture-51-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-52-id%2Fraw/unarchive","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-53-id%2Fraw/budget","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-40-id%2Fraw/export","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-41-id%2Fraw/sandbox-access","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-42-id%2Fraw","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-43-id%2Fraw/stop","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-44-id%2Fraw/sandbox-error","groups":{"id":"fixture-44-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-45-id%2Fraw/events","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-46-id%2Fraw/artifacts","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-47-id%2Fraw/participants","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-48-id%2Fraw/participant-profiles","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-49-id%2Fraw/messages","groups":{"id":"fixture-49-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-50-id%2Fraw/pr","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-51-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-51-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-52-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-53-id%2Fraw/scm-credentials","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-54-id%2Fraw/tunnel-urls","groups":{"id":"fixture-54-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-55-id%2Fraw/title","groups":{"id":"fixture-55-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-56-id%2Fraw/archive","groups":{"id":"fixture-56-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-57-id%2Fraw/unarchive","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-58-id%2Fraw/budget","groups":{"id":"fixture-58-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions/batch-archive","pathname":"/sessions/batch-archive","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.bulk_archive"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-55-id%2Fraw/ws-token","groups":{"id":"fixture-55-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-56-id%2Fraw/prompt","groups":{"id":"fixture-56-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-57-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-58-id%2Fraw/media","groups":{"id":"fixture-58-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-59-id%2Fraw/media/fixture-59-artifactId%2Fraw","groups":{"id":"fixture-59-id%2Fraw","artifactId":"fixture-59-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-60-id%2Fraw/attachments","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-61-id%2Fraw/attachments/fixture-61-attachmentId%2Fraw","groups":{"id":"fixture-61-id%2Fraw","attachmentId":"fixture-61-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-62-id%2Fraw/diff","groups":{"id":"fixture-62-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-63-id%2Fraw/diff","groups":{"id":"fixture-63-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-64-id%2Fraw/diff/failure","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-65-id%2Fraw/diff/fixture-65-revisionId%2Fraw/files/fixture-65-fileId%2Fraw","groups":{"id":"fixture-65-id%2Fraw","revisionId":"fixture-65-revisionId%2Fraw","fileId":"fixture-65-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-66-id%2Fraw/diff/retry","groups":{"id":"fixture-66-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-67-id%2Fraw/skills","groups":{"id":"fixture-67-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-68-id%2Fraw/sandbox-skills","groups":{"id":"fixture-68-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-69-id%2Fraw/children","groups":{"id":"fixture-69-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-70-id%2Fraw/children","groups":{"id":"fixture-70-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-71-id%2Fraw/children/fixture-71-childId%2Fraw","groups":{"id":"fixture-71-id%2Fraw","childId":"fixture-71-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-72-id%2Fraw/children/fixture-72-childId%2Fraw/cancel","groups":{"id":"fixture-72-id%2Fraw","childId":"fixture-72-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-73-id%2Fraw/children/fixture-73-childId%2Fraw/prompt","groups":{"id":"fixture-73-id%2Fraw","childId":"fixture-73-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-74-id%2Fraw/slack-notify","groups":{"id":"fixture-74-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-60-id%2Fraw/ws-token","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-61-id%2Fraw/prompt","groups":{"id":"fixture-61-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-62-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-62-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-63-id%2Fraw/media","groups":{"id":"fixture-63-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-64-id%2Fraw/media/fixture-64-artifactId%2Fraw","groups":{"id":"fixture-64-id%2Fraw","artifactId":"fixture-64-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-65-id%2Fraw/attachments","groups":{"id":"fixture-65-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-66-id%2Fraw/attachments/fixture-66-attachmentId%2Fraw","groups":{"id":"fixture-66-id%2Fraw","attachmentId":"fixture-66-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-67-id%2Fraw/diff","groups":{"id":"fixture-67-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-68-id%2Fraw/diff","groups":{"id":"fixture-68-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-69-id%2Fraw/diff/failure","groups":{"id":"fixture-69-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-70-id%2Fraw/diff/fixture-70-revisionId%2Fraw/files/fixture-70-fileId%2Fraw","groups":{"id":"fixture-70-id%2Fraw","revisionId":"fixture-70-revisionId%2Fraw","fileId":"fixture-70-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-71-id%2Fraw/diff/retry","groups":{"id":"fixture-71-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-72-id%2Fraw/skills","groups":{"id":"fixture-72-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-73-id%2Fraw/sandbox-skills","groups":{"id":"fixture-73-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-74-id%2Fraw/children","groups":{"id":"fixture-74-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-75-id%2Fraw/children","groups":{"id":"fixture-75-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-76-id%2Fraw/children/fixture-76-childId%2Fraw","groups":{"id":"fixture-76-id%2Fraw","childId":"fixture-76-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-77-id%2Fraw/children/fixture-77-childId%2Fraw/cancel","groups":{"id":"fixture-77-id%2Fraw","childId":"fixture-77-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-78-id%2Fraw/children/fixture-78-childId%2Fraw/prompt","groups":{"id":"fixture-78-id%2Fraw","childId":"fixture-78-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-79-id%2Fraw/slack-notify","groups":{"id":"fixture-79-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /repos","pathname":"/repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-76-owner%2Fraw/fixture-76-name%2Fraw/metadata","groups":{"owner":"fixture-76-owner%2Fraw","name":"fixture-76-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-77-owner%2Fraw/fixture-77-name%2Fraw/metadata","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-78-owner%2Fraw/fixture-78-name%2Fraw/branches","groups":{"owner":"fixture-78-owner%2Fraw","name":"fixture-78-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-79-owner%2Fraw/fixture-79-name%2Fraw/secrets","groups":{"owner":"fixture-79-owner%2Fraw","name":"fixture-79-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-80-owner%2Fraw/fixture-80-name%2Fraw/secrets","groups":{"owner":"fixture-80-owner%2Fraw","name":"fixture-80-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-81-owner%2Fraw/fixture-81-name%2Fraw/secrets/fixture-81-key%2Fraw","groups":{"owner":"fixture-81-owner%2Fraw","name":"fixture-81-name%2Fraw","key":"fixture-81-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-81-owner%2Fraw/fixture-81-name%2Fraw/metadata","groups":{"owner":"fixture-81-owner%2Fraw","name":"fixture-81-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-82-owner%2Fraw/fixture-82-name%2Fraw/metadata","groups":{"owner":"fixture-82-owner%2Fraw","name":"fixture-82-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-83-owner%2Fraw/fixture-83-name%2Fraw/branches","groups":{"owner":"fixture-83-owner%2Fraw","name":"fixture-83-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-84-owner%2Fraw/fixture-84-name%2Fraw/secrets","groups":{"owner":"fixture-84-owner%2Fraw","name":"fixture-84-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-85-owner%2Fraw/fixture-85-name%2Fraw/secrets","groups":{"owner":"fixture-85-owner%2Fraw","name":"fixture-85-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-86-owner%2Fraw/fixture-86-name%2Fraw/secrets/fixture-86-key%2Fraw","groups":{"owner":"fixture-86-owner%2Fraw","name":"fixture-86-name%2Fraw","key":"fixture-86-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /secrets","pathname":"/secrets","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /secrets","pathname":"/secrets","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-84-key%2Fraw","groups":{"key":"fixture-84-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-89-key%2Fraw","groups":{"key":"fixture-89-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /environments","pathname":"/environments","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /environments","pathname":"/environments","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id","pathname":"/environments/fixture-87-id%2Fraw","groups":{"id":"fixture-87-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-88-id%2Fraw","groups":{"id":"fixture-88-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-89-id%2Fraw","groups":{"id":"fixture-89-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-90-id%2Fraw/secrets","groups":{"id":"fixture-90-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"read"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-91-id%2Fraw/secrets","groups":{"id":"fixture-91-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-92-id%2Fraw/secrets/import","groups":{"id":"fixture-92-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-93-id%2Fraw/secrets/fixture-93-key%2Fraw","groups":{"id":"fixture-93-id%2Fraw","key":"fixture-93-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id","pathname":"/environments/fixture-92-id%2Fraw","groups":{"id":"fixture-92-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-93-id%2Fraw","groups":{"id":"fixture-93-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-94-id%2Fraw","groups":{"id":"fixture-94-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-95-id%2Fraw/secrets","groups":{"id":"fixture-95-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"read"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-96-id%2Fraw/secrets","groups":{"id":"fixture-96-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-97-id%2Fraw/secrets/import","groups":{"id":"fixture-97-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-98-id%2Fraw/secrets/fixture-98-key%2Fraw","groups":{"id":"fixture-98-id%2Fraw","key":"fixture-98-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-complete","pathname":"/image-builds/build-complete","groups":{},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-failed","pathname":"/image-builds/build-failed","groups":{},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-96-id%2Fraw","groups":{"id":"fixture-96-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-97-owner%2Fraw/fixture-97-name%2Fraw","groups":{"owner":"fixture-97-owner%2Fraw","name":"fixture-97-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-98-owner%2Fraw/fixture-98-name%2Fraw","groups":{"owner":"fixture-98-owner%2Fraw","name":"fixture-98-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-101-id%2Fraw","groups":{"id":"fixture-101-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"},{"kind":"environment","idParam":"id","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-102-owner%2Fraw/fixture-102-name%2Fraw","groups":{"owner":"fixture-102-owner%2Fraw","name":"fixture-102-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-103-owner%2Fraw/fixture-103-name%2Fraw","groups":{"owner":"fixture-103-owner%2Fraw","name":"fixture-103-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/status","pathname":"/image-builds/status","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled","pathname":"/image-builds/enabled","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled-repos","pathname":"/image-builds/enabled-repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", @@ -110,66 +115,66 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /model-provider-accounts/legacy-credentials","pathname":"/model-provider-accounts/legacy-credentials","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-108-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-108-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/device-authorizations/fixture-109-id%2Fraw/poll","groups":{"provider":"fixture-109-provider%2Fraw","id":"fixture-109-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/device-authorizations/fixture-110-id%2Fraw","groups":{"provider":"fixture-110-provider%2Fraw","id":"fixture-110-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-111-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-111-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-112-provider%2Fraw/authorization-codes/fixture-112-id%2Fraw","groups":{"provider":"fixture-112-provider%2Fraw","id":"fixture-112-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-113-provider%2Fraw/authorization-codes/fixture-113-id%2Fraw/complete","groups":{"provider":"fixture-113-provider%2Fraw","id":"fixture-113-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-114-provider%2Fraw/authorization-codes/fixture-114-id%2Fraw","groups":{"provider":"fixture-114-provider%2Fraw","id":"fixture-114-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-115-id%2Fraw","groups":{"id":"fixture-115-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-116-id%2Fraw","groups":{"id":"fixture-116-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-117-id%2Fraw/verify","groups":{"id":"fixture-117-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-118-id%2Fraw/disable","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-119-id%2Fraw/enable","groups":{"id":"fixture-119-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-120-id%2Fraw/reconnect","groups":{"id":"fixture-120-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-121-id%2Fraw","groups":{"id":"fixture-121-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-113-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-113-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-114-provider%2Fraw/device-authorizations/fixture-114-id%2Fraw/poll","groups":{"provider":"fixture-114-provider%2Fraw","id":"fixture-114-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-115-provider%2Fraw/device-authorizations/fixture-115-id%2Fraw","groups":{"provider":"fixture-115-provider%2Fraw","id":"fixture-115-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-116-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-116-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-117-provider%2Fraw/authorization-codes/fixture-117-id%2Fraw","groups":{"provider":"fixture-117-provider%2Fraw","id":"fixture-117-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-118-provider%2Fraw/authorization-codes/fixture-118-id%2Fraw/complete","groups":{"provider":"fixture-118-provider%2Fraw","id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-119-provider%2Fraw/authorization-codes/fixture-119-id%2Fraw","groups":{"provider":"fixture-119-provider%2Fraw","id":"fixture-119-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-120-id%2Fraw","groups":{"id":"fixture-120-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-121-id%2Fraw","groups":{"id":"fixture-121-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-122-id%2Fraw/verify","groups":{"id":"fixture-122-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-123-id%2Fraw/disable","groups":{"id":"fixture-123-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-124-id%2Fraw/enable","groups":{"id":"fixture-124-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-125-id%2Fraw/reconnect","groups":{"id":"fixture-125-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-126-id%2Fraw","groups":{"id":"fixture-126-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-account-defaults","pathname":"/model-provider-account-defaults","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-123-provider%2Fraw","groups":{"provider":"fixture-123-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-124-provider%2Fraw","groups":{"provider":"fixture-124-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-125-id%2Fraw/provider-auth/fixture-125-provider%2Fraw/access-token","groups":{"id":"fixture-125-id%2Fraw","provider":"fixture-125-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-126-id%2Fraw/provider-auth/fixture-126-provider%2Fraw/runtime-credential","groups":{"id":"fixture-126-id%2Fraw","provider":"fixture-126-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-127-id%2Fraw","groups":{"id":"fixture-127-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-128-id%2Fraw","groups":{"id":"fixture-128-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-129-id%2Fraw","groups":{"id":"fixture-129-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-130-id%2Fraw/repos","groups":{"id":"fixture-130-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-131-id%2Fraw/repos/fixture-131-owner%2Fraw/fixture-131-name%2Fraw","groups":{"id":"fixture-131-id%2Fraw","owner":"fixture-131-owner%2Fraw","name":"fixture-131-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-132-id%2Fraw/repos/fixture-132-owner%2Fraw/fixture-132-name%2Fraw","groups":{"id":"fixture-132-id%2Fraw","owner":"fixture-132-owner%2Fraw","name":"fixture-132-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-133-id%2Fraw/repos/fixture-133-owner%2Fraw/fixture-133-name%2Fraw","groups":{"id":"fixture-133-id%2Fraw","owner":"fixture-133-owner%2Fraw","name":"fixture-133-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-134-id%2Fraw/environments/fixture-134-environmentId%2Fraw","groups":{"id":"fixture-134-id%2Fraw","environmentId":"fixture-134-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"},{"kind":"environment","idParam":"environmentId","need":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-135-id%2Fraw/environments/fixture-135-environmentId%2Fraw","groups":{"id":"fixture-135-id%2Fraw","environmentId":"fixture-135-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"},{"kind":"environment","idParam":"environmentId","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-136-id%2Fraw/environments/fixture-136-environmentId%2Fraw","groups":{"id":"fixture-136-id%2Fraw","environmentId":"fixture-136-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"},{"kind":"environment","idParam":"environmentId","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-137-id%2Fraw/resolved/fixture-137-owner%2Fraw/fixture-137-name%2Fraw","groups":{"id":"fixture-137-id%2Fraw","owner":"fixture-137-owner%2Fraw","name":"fixture-137-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-128-provider%2Fraw","groups":{"provider":"fixture-128-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-129-provider%2Fraw","groups":{"provider":"fixture-129-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-130-id%2Fraw/provider-auth/fixture-130-provider%2Fraw/access-token","groups":{"id":"fixture-130-id%2Fraw","provider":"fixture-130-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-131-id%2Fraw/provider-auth/fixture-131-provider%2Fraw/runtime-credential","groups":{"id":"fixture-131-id%2Fraw","provider":"fixture-131-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-132-id%2Fraw","groups":{"id":"fixture-132-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-133-id%2Fraw","groups":{"id":"fixture-133-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-134-id%2Fraw","groups":{"id":"fixture-134-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-135-id%2Fraw/repos","groups":{"id":"fixture-135-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-136-id%2Fraw/repos/fixture-136-owner%2Fraw/fixture-136-name%2Fraw","groups":{"id":"fixture-136-id%2Fraw","owner":"fixture-136-owner%2Fraw","name":"fixture-136-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-137-id%2Fraw/repos/fixture-137-owner%2Fraw/fixture-137-name%2Fraw","groups":{"id":"fixture-137-id%2Fraw","owner":"fixture-137-owner%2Fraw","name":"fixture-137-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-138-id%2Fraw/repos/fixture-138-owner%2Fraw/fixture-138-name%2Fraw","groups":{"id":"fixture-138-id%2Fraw","owner":"fixture-138-owner%2Fraw","name":"fixture-138-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-139-id%2Fraw/environments/fixture-139-environmentId%2Fraw","groups":{"id":"fixture-139-id%2Fraw","environmentId":"fixture-139-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"},{"kind":"environment","idParam":"environmentId","need":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-140-id%2Fraw/environments/fixture-140-environmentId%2Fraw","groups":{"id":"fixture-140-id%2Fraw","environmentId":"fixture-140-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"},{"kind":"environment","idParam":"environmentId","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-141-id%2Fraw/environments/fixture-141-environmentId%2Fraw","groups":{"id":"fixture-141-id%2Fraw","environmentId":"fixture-141-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"},{"kind":"environment","idParam":"environmentId","need":"manage"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-142-id%2Fraw/resolved/fixture-142-owner%2Fraw/fixture-142-name%2Fraw","groups":{"id":"fixture-142-id%2Fraw","owner":"fixture-142-owner%2Fraw","name":"fixture-142-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-141-id%2Fraw/commit-signing","groups":{"id":"fixture-141-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-142-id%2Fraw/commit-signing","groups":{"id":"fixture-142-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-146-id%2Fraw/commit-signing","groups":{"id":"fixture-146-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-147-id%2Fraw/commit-signing","groups":{"id":"fixture-147-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings/repos","pathname":"/scm-settings/repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-147-owner%2Fraw/fixture-147-name%2Fraw","groups":{"owner":"fixture-147-owner%2Fraw","name":"fixture-147-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-148-owner%2Fraw/fixture-148-name%2Fraw","groups":{"owner":"fixture-148-owner%2Fraw","name":"fixture-148-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-152-owner%2Fraw/fixture-152-name%2Fraw","groups":{"owner":"fixture-152-owner%2Fraw","name":"fixture-152-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-153-owner%2Fraw/fixture-153-name%2Fraw","groups":{"owner":"fixture-153-owner%2Fraw","name":"fixture-153-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/watched-channels","pathname":"/integration-settings/slack/watched-channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/channels","pathname":"/integration-settings/slack/channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id","pathname":"/automations/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-155-id%2Fraw","groups":{"id":"fixture-155-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /automations/:id","pathname":"/automations/fixture-156-id%2Fraw","groups":{"id":"fixture-156-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-157-id%2Fraw/pause","groups":{"id":"fixture-157-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-158-id%2Fraw/resume","groups":{"id":"fixture-158-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-159-id%2Fraw/trigger","groups":{"id":"fixture-159-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-160-id%2Fraw/invocations","groups":{"id":"fixture-160-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-161-id%2Fraw/runs/fixture-161-runId%2Fraw","groups":{"id":"fixture-161-id%2Fraw","runId":"fixture-161-runId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-162-id%2Fraw/regenerate-key","groups":{"id":"fixture-162-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id","pathname":"/automations/fixture-158-id%2Fraw","groups":{"id":"fixture-158-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-159-id%2Fraw","groups":{"id":"fixture-159-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-160-id%2Fraw","groups":{"id":"fixture-160-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /automations/:id","pathname":"/automations/fixture-161-id%2Fraw","groups":{"id":"fixture-161-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-162-id%2Fraw/pause","groups":{"id":"fixture-162-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-163-id%2Fraw/resume","groups":{"id":"fixture-163-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-164-id%2Fraw/trigger","groups":{"id":"fixture-164-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-165-id%2Fraw/invocations","groups":{"id":"fixture-165-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-166-id%2Fraw/runs/fixture-166-runId%2Fraw","groups":{"id":"fixture-166-id%2Fraw","runId":"fixture-166-runId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"read","automationIdParam":"id"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-167-id%2Fraw/regenerate-key","groups":{"id":"fixture-167-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":"no-store","hasServiceActorClaims":false}", "{"identity":"GET /mcp-servers","pathname":"/mcp-servers","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /mcp-servers","pathname":"/mcp-servers","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-165-id%2Fraw","groups":{"id":"fixture-165-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-166-id%2Fraw","groups":{"id":"fixture-166-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-167-id%2Fraw","groups":{"id":"fixture-167-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-170-id%2Fraw","groups":{"id":"fixture-170-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-171-id%2Fraw","groups":{"id":"fixture-171-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-172-id%2Fraw","groups":{"id":"fixture-172-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/dashboard","pathname":"/analytics/dashboard","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/summary","pathname":"/analytics/summary","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/timeseries","pathname":"/analytics/timeseries","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", @@ -181,32 +186,32 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /skills","pathname":"/skills","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/preview","pathname":"/skills/preview","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/resolve-preview","pathname":"/skills/resolve-preview","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /skills/:id","pathname":"/skills/fixture-179-id%2Fraw","groups":{"id":"fixture-179-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skills/:id","pathname":"/skills/fixture-184-id%2Fraw","groups":{"id":"fixture-184-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills","pathname":"/skills","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import/preview","pathname":"/skills/import/preview","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import","pathname":"/skills/import","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-183-id%2Fraw/reimport/preview","groups":{"id":"fixture-183-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-184-id%2Fraw/reimport","groups":{"id":"fixture-184-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-185-id%2Fraw","groups":{"id":"fixture-185-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-186-id%2Fraw","groups":{"id":"fixture-186-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-187-id%2Fraw","groups":{"id":"fixture-187-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-188-id%2Fraw/reimport/preview","groups":{"id":"fixture-188-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-189-id%2Fraw/reimport","groups":{"id":"fixture-189-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-190-id%2Fraw","groups":{"id":"fixture-190-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-191-id%2Fraw","groups":{"id":"fixture-191-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-192-id%2Fraw","groups":{"id":"fixture-192-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-190-id%2Fraw","groups":{"id":"fixture-190-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-191-id%2Fraw","groups":{"id":"fixture-191-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-195-id%2Fraw","groups":{"id":"fixture-195-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-196-id%2Fraw","groups":{"id":"fixture-196-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PUT /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /me/authorization","pathname":"/me/authorization","groups":{},"authentication":"user","authorization":{"kind":"authenticated","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /roles","pathname":"/roles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /roles/:id","pathname":"/roles/fixture-196-id%2Fraw","groups":{"id":"fixture-196-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /roles/:id","pathname":"/roles/fixture-201-id%2Fraw","groups":{"id":"fixture-201-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /members","pathname":"/members","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-198-id%2Fraw/role","groups":{"id":"fixture-198-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-199-id%2Fraw/status","groups":{"id":"fixture-199-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-200-id%2Fraw/secrets","groups":{"id":"fixture-200-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-201-id%2Fraw/secrets","groups":{"id":"fixture-201-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-202-id%2Fraw/secrets/fixture-202-key%2Fraw","groups":{"id":"fixture-202-id%2Fraw","key":"fixture-202-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-203-id%2Fraw","groups":{"id":"fixture-203-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-204-id%2Fraw","groups":{"id":"fixture-204-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-203-id%2Fraw/role","groups":{"id":"fixture-203-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-204-id%2Fraw/status","groups":{"id":"fixture-204-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-205-id%2Fraw/secrets","groups":{"id":"fixture-205-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-206-id%2Fraw/secrets","groups":{"id":"fixture-206-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-207-id%2Fraw/secrets/fixture-207-key%2Fraw","groups":{"id":"fixture-207-id%2Fraw","key":"fixture-207-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-208-id%2Fraw","groups":{"id":"fixture-208-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-209-id%2Fraw","groups":{"id":"fixture-209-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-event","pathname":"/internal/github-event","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/slack-event","pathname":"/internal/slack-event","groups":{},"authentication":"service","authorization":{"kind":"service","services":["slack-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", ] diff --git a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap index 7f626debcf..3b445f84a0 100644 --- a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap @@ -27,6 +27,7 @@ exports[`route admission matrix > admits a session-bound sandbox token on every exports[`route admission matrix > admits only the named bot on every exact-service route 1`] = ` [ + "GET /channel-bindings/:provider/:externalId slack-bot=400 github-bot=403 web=403", "POST /internal/github-event github-bot=400 slack-bot=403 web=403", "POST /internal/slack-event slack-bot=400 github-bot=403 web=403", ] @@ -41,6 +42,10 @@ exports[`route admission matrix > admits the workspace owner through every brows "POST /api/auth/sign-out owner=403", "GET /api/auth/error owner=200", "GET /internal/auth/sign-in-providers owner=200", + "GET /teams/:id/channel-bindings owner=200", + "PUT /teams/:id/channel-bindings/:provider/:externalId owner=400", + "DELETE /teams/:id/channel-bindings/:provider/:externalId owner=400", + "GET /teams/:id/slack-channels owner=200", "GET /me/teams owner=200", "GET /teams owner=200", "POST /teams owner=400", @@ -237,6 +242,10 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "POST /api/auth/sign-out anonymous=401", "GET /api/auth/error anonymous=401", "GET /internal/auth/sign-in-providers anonymous=401", + "GET /teams/:id/channel-bindings anonymous=401", + "PUT /teams/:id/channel-bindings/:provider/:externalId anonymous=401", + "DELETE /teams/:id/channel-bindings/:provider/:externalId anonymous=401", + "GET /teams/:id/slack-channels anonymous=401", "GET /me/teams anonymous=401", "GET /teams anonymous=401", "POST /teams anonymous=401", @@ -254,6 +263,7 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "DELETE /teams/:id/repository-grants/:grantId anonymous=401", "GET /settings/teams anonymous=401", "PATCH /settings/teams anonymous=401", + "GET /channel-bindings/:provider/:externalId anonymous=401", "POST /sessions anonymous=401", "PUT /sessions/:id/visibility anonymous=401", "PUT /sessions/:id/collaborators/:userId anonymous=401", @@ -449,6 +459,10 @@ exports[`route admission sentinel > admits actorless reads of team and workspace exports[`route admission sentinel > admits team directory reads but conceals member tabs and denies capabilities in every mode 1`] = ` [ + "GET /teams/:id/channel-bindings off/nonmember=403 auditAllowed=true", + "PUT /teams/:id/channel-bindings/:provider/:externalId off/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/channel-bindings/:provider/:externalId off/nonmember=403 auditAllowed=true", + "GET /teams/:id/slack-channels off/nonmember=403 auditAllowed=true", "GET /teams/:id off/nonmember=200 auditAllowed=false", "PATCH /teams/:id off/nonmember=403 auditAllowed=true", "POST /teams/:id/archive off/nonmember=403 auditAllowed=true", @@ -464,6 +478,10 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "GET /teams/:id/secrets off/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets off/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key off/nonmember=403 auditAllowed=true", + "GET /teams/:id/channel-bindings shadow/nonmember=403 auditAllowed=true", + "PUT /teams/:id/channel-bindings/:provider/:externalId shadow/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/channel-bindings/:provider/:externalId shadow/nonmember=403 auditAllowed=true", + "GET /teams/:id/slack-channels shadow/nonmember=403 auditAllowed=true", "GET /teams/:id shadow/nonmember=200 auditAllowed=false", "PATCH /teams/:id shadow/nonmember=403 auditAllowed=true", "POST /teams/:id/archive shadow/nonmember=403 auditAllowed=true", @@ -479,6 +497,10 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "GET /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key shadow/nonmember=403 auditAllowed=true", + "GET /teams/:id/channel-bindings on/nonmember=403 auditAllowed=true", + "PUT /teams/:id/channel-bindings/:provider/:externalId on/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/channel-bindings/:provider/:externalId on/nonmember=403 auditAllowed=true", + "GET /teams/:id/slack-channels on/nonmember=403 auditAllowed=true", "GET /teams/:id on/nonmember=200 auditAllowed=false", "PATCH /teams/:id on/nonmember=403 auditAllowed=true", "POST /teams/:id/archive on/nonmember=403 auditAllowed=true", diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 8e5cf1f516..93840e673b 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -4,7 +4,9 @@ import { AutomationStore, type AutomationRow } from "../../src/db/automation-sto import { SlackChannelStore } from "../../src/db/slack-channel-store"; import { cleanD1Tables } from "./cleanup"; import { serviceFetch, sqlDatabase } from "./helpers"; +import { seedTeam } from "./ownership-test-helpers"; import type { TriggerConfig } from "@open-inspect/shared/triggers"; +import { TeamStore } from "../../src/db/teams"; function makeSlackAutomation(overrides?: Partial): AutomationRow { const now = Date.now(); @@ -55,6 +57,89 @@ async function postAutomation(body: Record): Promise describe("POST /automations — slack_event validation (integration)", () => { beforeEach(cleanD1Tables); + it("rejects a workspace automation watching a team-bound channel before repository resolution", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "bound", + name: "Bound", + joinPolicy: "invite_only", + }); + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', ?, 'source', ?)" + ) + .bind(team.id, Date.now()) + .run(); + const response = await postAutomation( + createBody({ + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + }, + }) + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "channel_team_mismatch" }); + expect((await env.DB.prepare("SELECT id FROM automations").all()).results).toEqual([]); + }); + + describe("team-owned", () => { + const EXECUTOR = "22222222222222222222222222222222"; + const TEAM = "team_slack_owner"; + const OTHER_TEAM = "team_slack_other"; + const as = { userId: EXECUTOR, role: "member" } as const; + + async function bindChannel(teamId: string) { + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', ?, 'source', ?)" + ) + .bind(teamId, Date.now()) + .run(); + } + + function postTeamAutomation(): Promise { + return serviceFetch("https://test.local/automations", { + method: "POST", + as, + body: JSON.stringify({ + name: "Team Slack triage", + instructions: "Investigate the report", + triggerType: "slack_event", + teamId: TEAM, + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + }, + }), + }); + } + + beforeEach(async () => { + expect((await serviceFetch("https://test.local/me/authorization", { as })).status).toBe(200); + await seedTeam(TEAM, [[EXECUTOR, "member"]]); + await seedTeam(OTHER_TEAM); + }); + + it("creates a firing candidate when the channel is bound to the automation's team", async () => { + await bindChannel(TEAM); + const response = await postTeamAutomation(); + expect(response.status).toBe(201); + const { automation } = await response.json<{ + automation: { id: string; ownerTeamId: string | null }; + }>(); + expect(automation.ownerTeamId).toBe(TEAM); + const candidates = await new SlackChannelStore(env.DB).getSlackAutomationsForChannel("C1"); + expect(candidates.map((row) => row.id)).toEqual([automation.id]); + }); + + it.each([ + ["unbound", null], + ["bound to another team", OTHER_TEAM], + ])("rejects a channel that is %s", async (_label, boundTeam) => { + if (boundTeam) await bindChannel(boundTeam); + const response = await postTeamAutomation(); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "channel_team_mismatch" }); + expect((await env.DB.prepare("SELECT id FROM automations").all()).results).toEqual([]); + }); + }); + it("rejects a slack_event without a slack_channel condition (400)", async () => { const res = await postAutomation(createBody({ triggerConfig: { conditions: [] } })); expect(res.status).toBe(400); @@ -148,23 +233,6 @@ describe("POST /automations — slack_event validation (integration)", () => { expect(res.status).toBe(400); expect(await res.text()).toContain("Unsupported regex flag"); }); - - it("accepts slack_event past the trigger-type allowlist (no unknown-trigger 400)", async () => { - // Valid scoping passes validation; the request then fails later at repository - // resolution (no GitHub App in the test env). The point is that slack_event is - // NOT rejected as an unknown trigger type before reaching that stage. - const res = await postAutomation( - createBody({ - triggerConfig: { - conditions: [ - { type: "slack_channel", operator: "any_of", value: ["C1"] }, - { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, - ], - }, - }) - ); - expect(await res.text()).not.toContain("triggerType must be one of"); - }); }); describe("PUT /automations/:id — slack_event validation (integration)", () => { @@ -177,6 +245,30 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => }); } + it("rejects writes after a watched channel moves to another team", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "bound", + name: "Bound", + joinPolicy: "invite_only", + }); + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation({ + trigger_config: JSON.stringify({ + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + }), + }); + await store.create(auto); + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', ?, 'source', ?)" + ) + .bind(team.id, Date.now()) + .run(); + const response = await putAutomation(auto.id, { name: "Changed" }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "channel_team_mismatch" }); + expect((await store.getById(auto.id))?.name).toBe(auto.name); + }); + it("rejects a non-array conditions on update with 400, not 500", async () => { const store = new AutomationStore(env.DB); const auto = makeSlackAutomation(); diff --git a/packages/control-plane/test/integration/environments-catalog.test.ts b/packages/control-plane/test/integration/environments-catalog.test.ts index a16908a79d..3b54862bca 100644 --- a/packages/control-plane/test/integration/environments-catalog.test.ts +++ b/packages/control-plane/test/integration/environments-catalog.test.ts @@ -1,15 +1,19 @@ import { env } from "cloudflare:test"; import { beforeEach, describe, expect, it } from "vitest"; import { EnvironmentStore } from "../../src/db/environments"; +import { TeamChannelBindingStore } from "../../src/db/team-channel-bindings"; import { TeamMembershipStore } from "../../src/db/team-memberships"; import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; import { TeamStore } from "../../src/db/teams"; +import { UserStore } from "../../src/db/user-store"; import { cleanD1Tables } from "./cleanup"; import { seedActiveUser, serviceFetch } from "./helpers"; const BASE = "https://test.local/environments"; const MEMBER = "22222222222222222222222222222222"; const OTHER = "33333333333333333333333333333333"; +const SLACK_ACTOR = { service: "slack-bot", actor: "slack:U-CATALOG" } as const; +const CHANNEL_URL = `${BASE}?channel=slack:C-CATALOG`; describe("team-scoped environment catalog", () => { let teamId: string; @@ -18,6 +22,11 @@ describe("team-scoped environment catalog", () => { await cleanD1Tables(); await seedActiveUser(MEMBER); await seedActiveUser(OTHER); + await new UserStore(env.DB).createIdentity({ + userId: MEMBER, + provider: "slack", + providerUserId: "U-CATALOG", + }); teamId = ( await new TeamStore(env.DB).create({ slug: "engineering", @@ -56,6 +65,117 @@ describe("team-scoped environment catalog", () => { } }); + it("derives live Slack team catalogs despite old/dual membership and only permits narrower selectors", async () => { + const bindings = new TeamChannelBindingStore(env.DB); + const bindingActor = { requestId: "catalog-binding", actorUserId: MEMBER }; + await bindings.put( + { provider: "slack", externalId: "C-CATALOG", teamId, kind: "source" }, + bindingActor + ); + const grants = new TeamRepositoryGrantStore(env.DB); + await grants.add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + expect(await (await serviceFetch(CHANNEL_URL, SLACK_ACTOR)).json()).toMatchObject({ + environments: [expect.objectContaining({ id: "env_covered" })], + total: 1, + }); + const otherTeam = await new TeamStore(env.DB).create({ + slug: "other", + name: "Other", + joinPolicy: "invite_only", + }); + const store = new EnvironmentStore(env.DB); + const original = (await store.getById("env_denied"))!; + await store.create( + { ...original, id: "env_other", name: "Other", owner_team_id: otherTeam.id }, + await store.getRepositoriesForEnvironment("env_denied") + ); + const grant = await grants.add(otherTeam.id, { + kind: "repository", + repoExternalId: 2, + owner: "acme", + name: "repo-2", + }); + await bindings.remove(teamId, "slack", "C-CATALOG", bindingActor); + await bindings.put( + { provider: "slack", externalId: "C-CATALOG", teamId: otherTeam.id, kind: "source" }, + bindingActor + ); + // The actor is still a member of the old team; the binding must not select its catalog. + const denied = await serviceFetch(CHANNEL_URL, SLACK_ACTOR); + expect(denied.status).toBe(404); + await new TeamMembershipStore(env.DB).add(otherTeam.id, MEMBER); + for (const query of ["", `&ownerTeamId=${otherTeam.id}`]) { + expect( + await (await serviceFetch(`${CHANNEL_URL}${query}`, SLACK_ACTOR)).json() + ).toMatchObject({ + environments: [expect.objectContaining({ id: "env_other" })], + total: 1, + }); + } + for (const query of [`&ownerTeamId=${teamId}`, "&ownerTeamId=null"]) { + expect(await (await serviceFetch(`${CHANNEL_URL}${query}`, SLACK_ACTOR)).json()).toEqual({ + environments: [], + total: 0, + }); + } + await grants.remove(otherTeam.id, grant.id); + expect(await (await serviceFetch(CHANNEL_URL, SLACK_ACTOR)).json()).toEqual({ + environments: [], + total: 0, + }); + }); + + it.each(["member", "administrator"] as const)( + "limits unbound Slack channels to workspace environments for a multi-team %s without changing browser catalogs", + async (role) => { + const otherTeam = await new TeamStore(env.DB).create({ + slug: "other", + name: "Other", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(otherTeam.id, MEMBER); + const store = new EnvironmentStore(env.DB); + const original = (await store.getById("env_denied"))!; + await store.create( + { ...original, id: "env_other", name: "Other", owner_team_id: otherTeam.id }, + await store.getRepositoriesForEnvironment("env_denied") + ); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(`role_builtin_${role}`, MEMBER) + .run(); + const scoped = await serviceFetch(CHANNEL_URL, SLACK_ACTOR); + expect(scoped.status).toBe(200); + const catalog = await scoped.json<{ + environments: { id: string; ownerTeamId: string | null }[]; + }>(); + expect(catalog.environments.map((row) => row.id).sort()).toEqual([ + "env_covered", + "env_empty", + "env_multi", + "env_nullable", + ]); + expect(catalog.environments.every((row) => row.ownerTeamId === null)).toBe(true); + for (const id of [teamId, otherTeam.id]) { + expect( + await (await serviceFetch(`${CHANNEL_URL}&ownerTeamId=${id}`, SLACK_ACTOR)).json() + ).toEqual({ environments: [], total: 0 }); + } + expect( + await (await serviceFetch(BASE, { as: { userId: MEMBER, role } })).json() + ).toMatchObject({ + total: 6, + }); + expect(await (await serviceFetch(BASE, { service: "slack-bot" })).json()).toMatchObject({ + total: 4, + }); + } + ); + it("filters persisted refs without SCM configuration, and retains the workspace catalog", async () => { const scopedUrl = `${BASE}?teamId=${teamId}`; const member = { as: { userId: MEMBER, role: "member" as const } }; diff --git a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts index 27f671ff25..2e19c3e932 100644 --- a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts +++ b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts @@ -45,7 +45,7 @@ describe("Hono route catalog conformance", () => { }; }); - expect(manifest).toHaveLength(207); + expect(manifest).toHaveLength(212); // One compact, reviewable line per frozen route keeps the fixture explicit // without thousands of snapshot-only formatting lines. expect(manifest.map((entry) => JSON.stringify(entry))).toMatchSnapshot(); diff --git a/packages/control-plane/test/integration/route-admission-matrix.test.ts b/packages/control-plane/test/integration/route-admission-matrix.test.ts index 51159f3c84..1251e9a932 100644 --- a/packages/control-plane/test/integration/route-admission-matrix.test.ts +++ b/packages/control-plane/test/integration/route-admission-matrix.test.ts @@ -264,21 +264,24 @@ describe("route admission matrix", { timeout: MATRIX_TIMEOUT_MS }, () => { const admitted = await serviceFetch(url, { method: route.method, service: allowedService, - body: "{}", + ...(isMutation(route) ? { body: "{}" } : {}), }); expect(PROTECTED_STATUSES.has(admitted.status), `${identity} allowed bot`).toBe(false); const wrongBot = await serviceFetch(url, { method: route.method, service: deniedService, - body: "{}", + ...(isMutation(route) ? { body: "{}" } : {}), }); expect(wrongBot.status, `${identity} wrong bot`).toBe(403); await expect(wrongBot.json(), identity).resolves.toMatchObject({ code: "service_capability_required", }); - const browser = await serviceFetch(url, { method: route.method, body: "{}" }); + const browser = await serviceFetch(url, { + method: route.method, + ...(isMutation(route) ? { body: "{}" } : {}), + }); expect(browser.status, `${identity} browser owner`).toBe(403); await expect(browser.json(), identity).resolves.toMatchObject({ code: "service_capability_required", diff --git a/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts b/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts index 6a656aac47..20f5f1aaa4 100644 --- a/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts +++ b/packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts @@ -9,6 +9,7 @@ import { AutomationStore } from "../../src/db/automation-store"; import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; import { SessionIndexStore } from "../../src/db/session-index"; import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import { TeamChannelBindingStore } from "../../src/db/team-channel-bindings"; import { TeamMembershipStore } from "../../src/db/team-memberships"; import { Scheduler } from "../../src/scheduler/scheduler"; import { cleanD1Tables } from "./cleanup"; @@ -54,7 +55,8 @@ function createSteeringScheduler(mode: "off" | "shadow" | "on" = "shadow") { async function seedSteerableSession( sessionId = "session-steering", ownerTeamId: string | null = SESSION_TEAM, - visibility: SessionVisibility = "private" + visibility: SessionVisibility = "private", + automationTeamId: string | null = ownerTeamId ) { const automationId = `auto-${sessionId}`; const now = Date.now(); @@ -67,7 +69,7 @@ async function seedSteerableSession( ) .bind( automationId, - AUTOMATION_TEAM, + automationTeamId, SESSION_OWNER, SESSION_OWNER, now, @@ -144,6 +146,10 @@ describe("Scheduler Slack team steering (real D1)", () => { [WORKSPACE_OWNER, "member"], ]); await seedTeam(AUTOMATION_TEAM, [[SESSION_OWNER, "member"]]); + await new TeamChannelBindingStore(env.DB).put( + { provider: "slack", externalId: "C1", teamId: SESSION_TEAM, kind: "source" }, + { actorUserId: SESSION_OWNER, requestId: "steering-fixture" } + ); }); afterEach(async () => { vi.restoreAllMocks(); @@ -173,6 +179,10 @@ describe("Scheduler Slack team steering (real D1)", () => { "preserves workspace collaboration-only steering in %s mode", async (mode) => { const { automationId } = await seedSteerableSession("session-steering", null, "workspace"); + await new TeamChannelBindingStore(env.DB).remove(SESSION_TEAM, "slack", "C1", { + actorUserId: SESSION_OWNER, + requestId: "workspace-steering-fixture", + }); await assignCustomRole(MEMBER, ["sessions.collaborate"]); const { scheduler, requests } = createSteeringScheduler(mode); const result = await scheduler.event(slackEvent(MEMBER)); @@ -266,7 +276,8 @@ describe("Scheduler Slack team steering (real D1)", () => { const targets = [ await seedSteerableSession("1-denied"), await seedSteerableSession("2-allowed"), - await seedSteerableSession("3-foreign", AUTOMATION_TEAM), + // Keep this candidate in the channel's team so session authorization must reject it. + await seedSteerableSession("3-foreign", AUTOMATION_TEAM, "private", SESSION_TEAM), ]; const collaborators = new SessionCollaboratorStore(env.DB); await collaborators.add("2-allowed", MEMBER, SESSION_OWNER); diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts index 22cad16d0d..17bb4e1aa7 100644 --- a/packages/control-plane/test/integration/service-auth.test.ts +++ b/packages/control-plane/test/integration/service-auth.test.ts @@ -360,7 +360,7 @@ describe("sig1 service-credential authentication", () => { const collaborator = await signedFetch({ service: "slack-bot", method: "POST", - url: `https://test.local/sessions/${createdBody.sessionId}/prompt`, + url: `https://test.local/sessions/${createdBody.sessionId}/prompt?channel=slack:C1`, actor: "slack:U0002", body: JSON.stringify({ content: "Cross-session prompt" }), }); diff --git a/packages/control-plane/test/integration/session-access-routes.test.ts b/packages/control-plane/test/integration/session-access-routes.test.ts index cb39e1f222..764240fd1d 100644 --- a/packages/control-plane/test/integration/session-access-routes.test.ts +++ b/packages/control-plane/test/integration/session-access-routes.test.ts @@ -1,8 +1,12 @@ import { createExecutionContext, env } from "cloudflare:test"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildServiceAuthHeaders } from "@open-inspect/shared/service-auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamChannelBindingStore } from "../../src/db/team-channel-bindings"; import { TeamStore } from "../../src/db/teams"; import { TeamMembershipStore } from "../../src/db/team-memberships"; import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { UserStore } from "../../src/db/user-store"; import { cleanD1Tables } from "./cleanup"; import { initSession, @@ -17,6 +21,8 @@ const BASE = "https://test.local"; const OWNER = "11111111111111111111111111111111"; const MEMBER = "22222222222222222222222222222222"; const CREATOR = "33333333333333333333333333333333"; +const SLACK_WRITES = ["prompt", "attachments"] as const; +const SCOPE_REFUSAL = { error: "Slack channel scope denied", code: "slack_channel_scope_denied" }; async function fetchMode( path: string, @@ -25,7 +31,8 @@ async function fetchMode( method?: string; as?: { userId: string; role: "owner" | "administrator" | "member" | "viewer" }; body?: string; - service?: "linear-bot"; + service?: "github-bot" | "linear-bot" | "slack-bot"; + actor?: string; } = {} ) { const url = `${BASE}${path}`; @@ -38,6 +45,7 @@ async function fetchMode( body: options.body, as: options.as, service: options.service, + actor: options.actor, }), body: options.body, }), @@ -46,6 +54,49 @@ async function fetchMode( ); } +async function slackWrite( + path: string, + mode: string, + options: { actor?: string | null; signedPath?: string } = {} +) { + const url = `${BASE}${path}`; + const form = new FormData(); + form.append( + "file", + new File([Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "image.png", { + type: "image/png", + }) + ); + const upload = new URL(url).pathname.endsWith("/attachments"); + const request = new Request(url, { + method: "POST", + headers: upload ? undefined : { "Content-Type": "application/json" }, + body: upload + ? form + : JSON.stringify({ + content: "Scope check", + source: "web", + callbackContext: { + source: "slack", + channel: "C-UNBOUND", + threadTs: "1.0", + repoFullName: "acme/web-app", + model: "anthropic/claude-haiku-4-5", + }, + }), + }); + const headers = await buildServiceAuthHeaders({ + service: "slack-bot", + secret: "test-service-secret-slack-bot", + method: "POST", + url: `${BASE}${options.signedPath ?? path}`, + body: await request.clone().arrayBuffer(), + actor: options.actor === null ? undefined : (options.actor ?? "slack:U-SCOPE"), + }); + for (const [name, value] of Object.entries(headers)) request.headers.set(name, value); + return routeRequest(request, { ...env, TEAMS_ENFORCEMENT: mode }, createExecutionContext()); +} + async function auditRows(action: string) { return ( await env.DB.prepare( @@ -70,6 +121,8 @@ describe("HTTP session access by enforcement mode", () => { await seedActiveUser(CREATOR); }); + afterEach(() => vi.restoreAllMocks()); + async function session(visibility: "team" | "private" | "workspace") { const team = await new TeamStore(env.DB).create({ slug: `access-${crypto.randomUUID()}`, @@ -84,6 +137,22 @@ describe("HTTP session access by enforcement mode", () => { return { sessionName, team, stub }; } + async function otherTeam() { + return new TeamStore(env.DB).create({ + slug: "other", + name: "Other", + joinPolicy: "invite_only", + }); + } + + async function bindSlackChannel(teamId: string) { + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', ?, 'source', ?)" + ) + .bind(teamId, Date.now()) + .run(); + } + it("conceals a team session on read and token mint when enforcement is on", async () => { const { sessionName, team } = await session("team"); const as = { userId: MEMBER, role: "member" } as const; @@ -100,6 +169,406 @@ describe("HTTP session access by enforcement mode", () => { expect(denied.find((row) => row.reason_code === "session_not_visible")?.team_id).toBe(team.id); }); + it("uses the signed Slack channel binding for actorless event reads", async () => { + const { sessionName, team } = await session("team"); + const other = await otherTeam(); + await bindSlackChannel(other.id); + const runtime = vi.spyOn(env.SESSION, "get"); + const hidden = await fetchMode(`/sessions/${sessionName}/events?channel=slack:C1`, "on", { + service: "slack-bot", + }); + expect(hidden.status).toBe(404); + expect(await hidden.json()).toEqual({ error: "Session not found" }); + expect(runtime).not.toHaveBeenCalled(); + runtime.mockRestore(); + await env.DB.prepare("UPDATE team_channel_bindings SET team_id = ? WHERE external_id = 'C1'") + .bind(team.id) + .run(); + expect( + ( + await fetchMode(`/sessions/${sessionName}/events?channel=slack:C1`, "on", { + service: "slack-bot", + }) + ).status + ).toBe(200); + expect( + ( + await fetchMode(`/sessions/${sessionName}/events`, "on", { + service: "slack-bot", + }) + ).status + ).toBe(200); + }); + + it("keeps a participant's concealed prompt separate from trusted channel publication access", async () => { + const { sessionName, team } = await session("team"); + await bindSlackChannel(team.id); + const denied = await fetchMode(`/sessions/${sessionName}/prompt`, "on", { + as: { userId: MEMBER, role: "member" }, + method: "POST", + body: JSON.stringify({ content: "not admitted" }), + }); + expect(denied.status).toBe(404); + expect(await denied.json()).toEqual({ error: "Session not found" }); + const proofPath = `/sessions/${sessionName}/artifacts?channel=slack:C1&purpose=slack-post`; + const proof = await fetchMode(proofPath, "on", { service: "slack-bot" }); + expect(proof.status).toBe(200); + expect(await proof.json()).toMatchObject({ artifacts: [] }); + await env.DB.prepare("UPDATE sessions SET visibility = 'private' WHERE id = ?") + .bind(sessionName) + .run(); + const unavailable = await fetchMode(proofPath, "on", { service: "slack-bot" }); + expect(unavailable.status).toBe(404); + expect(await unavailable.json()).toEqual({ error: "Session not found" }); + }); + + it.each(["slack:", "unknown:C1", "linear:C1", "slack:C1&channel=slack:C2"])( + "fails closed for invalid actorless channel scope %s", + async (channel) => { + const { sessionName } = await session("team"); + expect( + ( + await fetchMode(`/sessions/${sessionName}/events?channel=${channel}`, "on", { + service: "slack-bot", + }) + ).status + ).toBe(404); + } + ); + + it.each(["off", "shadow"])( + "retains %s semantics for channel-scoped service reads", + async (mode) => { + const { sessionName } = await session("team"); + const other = await otherTeam(); + await bindSlackChannel(other.id); + expect( + ( + await fetchMode(`/sessions/${sessionName}/events?channel=slack:C1`, mode, { + service: "slack-bot", + }) + ).status + ).toBe(200); + } + ); + + it.each(["off", "shadow", "on"])( + "blocks queued Slack publication after channel rebinding in %s mode", + async (mode) => { + const { sessionName, team } = await session("workspace"); + const other = await otherTeam(); + await bindSlackChannel(team.id); + const path = `/sessions/${sessionName}/events?channel=slack:C1&purpose=slack-post`; + expect((await fetchMode(path, mode, { service: "slack-bot" })).status).toBe(200); + await env.DB.prepare( + "UPDATE team_channel_bindings SET team_id = ? WHERE provider = 'slack' AND external_id = 'C1'" + ) + .bind(other.id) + .run(); + expect( + ( + await fetchMode(`/sessions/${sessionName}/events?channel=slack:C1`, mode, { + service: "slack-bot", + }) + ).status + ).toBe(200); + const runtime = vi.spyOn(env.SESSION, "get"); + for (const resource of ["events", "artifacts", "media/artifact_1"]) { + expect( + ( + await fetchMode( + `/sessions/${sessionName}/${resource}?channel=slack:C1&purpose=slack-post`, + mode, + { service: "slack-bot" } + ) + ).status + ).toBe(404); + } + expect(runtime).not.toHaveBeenCalled(); + runtime.mockRestore(); + expect((await auditRows("authorization.request_denied")).slice(-3)).toMatchObject([ + { team_id: team.id }, + { team_id: team.id }, + { team_id: team.id }, + ]); + } + ); + + describe.each(["off", "shadow", "on"])("Slack write scope in %s mode", (mode) => { + it("admits same-team and workspace/unbound writes", async () => { + const { sessionName, team } = await session("team"); + await new UserStore(env.DB).createIdentity({ + userId: MEMBER, + provider: "slack", + providerUserId: "U-SCOPE", + }); + await new TeamMembershipStore(env.DB).add(team.id, MEMBER); + await bindSlackChannel(team.id); + const workspace = await initSession({ userId: CREATOR }); + await waitForSandboxStatus(workspace.stub, "failed"); + for (const [id, channel] of [ + [sessionName, "C1"], + [workspace.sessionName, "C-UNBOUND"], + ]) { + for (const resource of SLACK_WRITES) { + const response = await slackWrite( + `/sessions/${id}/${resource}?channel=slack:${channel}`, + mode + ); + expect(response.status).toBe(resource === "prompt" ? 200 : 201); + } + } + }); + + it("denies live rebind/unbind despite original or dual membership, before any writes", async () => { + const { sessionName, team, stub } = await session("workspace"); + const other = await otherTeam(); + const users = new UserStore(env.DB); + await users.createIdentity({ userId: MEMBER, provider: "slack", providerUserId: "U-SCOPE" }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, MEMBER); + await bindSlackChannel(team.id); + for (const resource of SLACK_WRITES) { + const response = await slackWrite( + `/sessions/${sessionName}/${resource}?channel=slack:C1`, + mode + ); + expect(response.status).toBe(resource === "prompt" ? 200 : 201); + } + const countsSql = `SELECT + (SELECT COUNT(*) FROM messages) AS messages, + (SELECT COUNT(*) FROM attachments) AS attachments, + (SELECT COUNT(*) FROM participants) AS participants`; + const before = await queryDO(stub, countsSql); + const runtime = vi.spyOn(env.SESSION, "get"); + const storage = vi.spyOn(env.MEDIA_BUCKET, "put"); + const enroll = vi.spyOn(UserStore.prototype, "resolveOrCreateUser"); + for (const state of ["rebound", "dual-member", "unbound"] as const) { + if (state === "rebound") { + await env.DB.prepare( + "UPDATE team_channel_bindings SET team_id = ? WHERE external_id = 'C1'" + ) + .bind(other.id) + .run(); + } else if (state === "dual-member") { + await memberships.add(other.id, MEMBER); + } else { + await env.DB.prepare("DELETE FROM team_channel_bindings WHERE external_id = 'C1'").run(); + } + for (const resource of SLACK_WRITES) { + for (const actor of ["slack:U-SCOPE", "slack:U-FIRST-CONTACT"]) { + const response = await slackWrite( + `/sessions/${sessionName}/${resource}?channel=slack:C1&teamId=${team.id}`, + mode, + { actor } + ); + expect(response.status, `${state} ${resource} ${actor}`).toBe(403); + expect(await response.json()).toEqual(SCOPE_REFUSAL); + } + } + } + expect(runtime).not.toHaveBeenCalled(); + expect(storage).not.toHaveBeenCalled(); + expect(enroll).not.toHaveBeenCalled(); + expect(await users.getIdentity("slack", "U-FIRST-CONTACT")).toBeNull(); + expect(await queryDO(stub, countsSql)).toEqual(before); + const denials = await auditRows("authorization.request_denied"); + expect(denials).toHaveLength(12); + expect(denials.every((row) => row.reason_code === SCOPE_REFUSAL.code)).toBe(true); + }); + + it("denies bound channels writing to workspace-owned sessions", async () => { + const { sessionName, team } = await session("workspace"); + await env.DB.prepare("UPDATE sessions SET owner_team_id = NULL WHERE id = ?") + .bind(sessionName) + .run(); + await bindSlackChannel(team.id); + const runtime = vi.spyOn(env.SESSION, "get"); + const storage = vi.spyOn(env.MEDIA_BUCKET, "put"); + const enroll = vi.spyOn(UserStore.prototype, "resolveOrCreateUser"); + for (const resource of SLACK_WRITES) { + const response = await slackWrite( + `/sessions/${sessionName}/${resource}?channel=slack:C1`, + mode + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual(SCOPE_REFUSAL); + } + expect(runtime).not.toHaveBeenCalled(); + expect(storage).not.toHaveBeenCalled(); + expect(enroll).not.toHaveBeenCalled(); + }); + + it("requires exactly one well-formed Slack query coordinate before enrollment or dispatch", async () => { + const binding = vi.spyOn(TeamChannelBindingStore.prototype, "get"); + const sessionRead = vi.spyOn(SessionIndexStore.prototype, "get"); + const enroll = vi.spyOn(UserStore.prototype, "resolveOrCreateUser"); + const runtime = vi.spyOn(env.SESSION, "get"); + const storage = vi.spyOn(env.MEDIA_BUCKET, "put"); + for (const query of [ + "", + "?channel=", + "?channel=slack:", + "?channel=slack:C1:extra", + "?channel=slack:C%201", + "?channel=unknown:C1", + "?channel=linear:C1", + "?channel=slack:C1&channel=slack:C1", + "?channel=slack:C1&channel=slack:C2", + ]) { + for (const resource of SLACK_WRITES) { + const response = await slackWrite(`/sessions/missing/${resource}${query}`, mode); + expect(response.status, `${resource} ${query}`).toBe(400); + expect(await response.json()).toEqual(SCOPE_REFUSAL); + } + } + expect(binding).not.toHaveBeenCalled(); + expect(sessionRead).not.toHaveBeenCalled(); + expect(enroll).not.toHaveBeenCalled(); + expect(runtime).not.toHaveBeenCalled(); + expect(storage).not.toHaveBeenCalled(); + }); + + it("retains the scope code for missing sessions and either authority read failure", async () => { + const runtime = vi.spyOn(env.SESSION, "get"); + const storage = vi.spyOn(env.MEDIA_BUCKET, "put"); + const enroll = vi.spyOn(UserStore.prototype, "resolveOrCreateUser"); + for (const authority of [null, TeamChannelBindingStore, SessionIndexStore]) { + const read = authority + ? vi + .spyOn(authority.prototype, "get") + .mockRejectedValue(new Error("Authority unavailable")) + : null; + for (const resource of SLACK_WRITES) { + const response = await slackWrite(`/sessions/missing/${resource}?channel=slack:C1`, mode); + expect(response.status).toBe(authority ? 503 : 404); + expect(await response.json()).toEqual(SCOPE_REFUSAL); + } + read?.mockRestore(); + } + expect(enroll).not.toHaveBeenCalled(); + expect(runtime).not.toHaveBeenCalled(); + expect(storage).not.toHaveBeenCalled(); + }); + + it("preserves actorless rejection and cryptographically rejects changed query coordinates", async () => { + const binding = vi.spyOn(TeamChannelBindingStore.prototype, "get"); + const enroll = vi.spyOn(UserStore.prototype, "resolveOrCreateUser"); + const runtime = vi.spyOn(env.SESSION, "get"); + for (const resource of SLACK_WRITES) { + const path = `/sessions/missing/${resource}`; + for (const query of ["", "?channel=slack:C1"]) { + const response = await slackWrite(`${path}${query}`, mode, { actor: null }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "service_actor_required" }); + } + for (const query of ["", "?channel=slack:C2", "?channel=slack:C1&channel=slack:C2"]) { + const response = await slackWrite(`${path}${query}`, mode, { + signedPath: `${path}?channel=slack:C1`, + }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + } + } + expect(binding).not.toHaveBeenCalled(); + expect(enroll).not.toHaveBeenCalled(); + expect(runtime).not.toHaveBeenCalled(); + }); + + it("still enforces membership, role, suspension, and private collaboration after a scope match", async () => { + const { sessionName, team } = await session("workspace"); + await new UserStore(env.DB).createIdentity({ + userId: MEMBER, + provider: "slack", + providerUserId: "U-SCOPE", + }); + await bindSlackChannel(team.id); + for (const [state, status, body] of [ + [ + "nonmember", + 403, + { error: "Forbidden", code: "session_action_denied", reason_code: "not_member" }, + ], + [ + "viewer", + 403, + { error: "Forbidden", code: "session_action_denied", reason_code: "missing_permission" }, + ], + ["suspended", 403, { error: "Forbidden", code: "active_user_required" }], + ["private", 404, { error: "Session not found" }], + ] as const) { + if (state === "viewer") { + await new TeamMembershipStore(env.DB).add(team.id, MEMBER); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?" + ) + .bind(MEMBER) + .run(); + } else if (state === "suspended") { + await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(MEMBER).run(); + } else if (state === "private") { + await env.DB.batch([ + env.DB.prepare("UPDATE users SET suspended_at = NULL WHERE id = ?").bind(MEMBER), + env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_member' WHERE user_id = ?" + ).bind(MEMBER), + env.DB.prepare("UPDATE sessions SET visibility = 'private' WHERE id = ?").bind( + sessionName + ), + ]); + } + for (const resource of SLACK_WRITES) { + const response = await slackWrite( + `/sessions/${sessionName}/${resource}?channel=slack:C1`, + mode + ); + expect(response.status, `${state} ${resource}`).toBe(status); + expect(await response.json()).toEqual(body); + } + } + await new SessionCollaboratorStore(env.DB).add(sessionName, MEMBER, CREATOR); + for (const resource of SLACK_WRITES) { + const response = await slackWrite( + `/sessions/${sessionName}/${resource}?channel=slack:C1`, + mode + ); + expect(response.status).toBe(resource === "prompt" ? 200 : 201); + } + }); + + it("leaves other services and Slack collaborate/media routes outside this scope gate", async () => { + const { sessionName, stub } = await initSession({ userId: CREATOR }); + await waitForSandboxStatus(stub, "failed"); + for (const service of ["linear-bot", "github-bot"] as const) { + for (const resource of SLACK_WRITES) { + const response = await fetchMode(`/sessions/${sessionName}/${resource}`, mode, { + service, + actor: `${service.replace("-bot", "")}:U-OUTSIDE-SCOPE`, + method: "POST", + body: JSON.stringify({ content: "Other integration" }), + }); + expect(response.status).toBe(resource === "prompt" ? 200 : 400); + if (resource === "attachments") { + expect(await response.json()).toEqual({ error: "Invalid multipart form data" }); + } + } + } + for (const [resource, message] of [ + ["pr", "title and body are required"], + ["media", "Invalid multipart form data"], + ]) { + const response = await fetchMode(`/sessions/${sessionName}/${resource}`, mode, { + service: "slack-bot", + actor: "slack:U-OUTSIDE-SCOPE", + method: "POST", + body: "{}", + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: message }); + } + }); + }); + it("defers the team and delete rules in shadow but records each would-be denial", async () => { const { sessionName, team } = await session("team"); const as = { userId: MEMBER, role: "member" } as const; diff --git a/packages/control-plane/test/integration/slack-channel-store.test.ts b/packages/control-plane/test/integration/slack-channel-store.test.ts index 0099e3d800..fee37b08b1 100644 --- a/packages/control-plane/test/integration/slack-channel-store.test.ts +++ b/packages/control-plane/test/integration/slack-channel-store.test.ts @@ -4,6 +4,7 @@ import { sqlDatabase } from "./helpers"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; import { SlackChannelStore } from "../../src/db/slack-channel-store"; import { cleanD1Tables } from "./cleanup"; +import { TeamStore } from "../../src/db/teams"; function makeAutomation(overrides?: Partial): AutomationRow { const now = Date.now(); @@ -77,4 +78,44 @@ describe("SlackChannelStore (D1 integration)", () => { expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2", "C3"]); }); + + it("selects only automations owned by the channel's current team", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "a", + name: "A", + joinPolicy: "invite_only", + }); + const other = await new TeamStore(env.DB).create({ + slug: "b", + name: "B", + joinPolicy: "invite_only", + }); + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + for (const [id, ownerTeamId] of [ + ["workspace", null], + ["matching", team.id], + ["other", other.id], + ] as const) { + await store.create(makeSlackAutomation({ id, owner_team_id: ownerTeamId })); + await sqlDatabase(env.DB).batch(channels.bindChannelStatements(id, ["C1"])); + } + expect((await channels.getSlackAutomationsForChannel("C1")).map((row) => row.id)).toEqual([ + "workspace", + ]); + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', ?, 'source', ?)" + ) + .bind(team.id, Date.now()) + .run(); + expect((await channels.getSlackAutomationsForChannel("C1")).map((row) => row.id)).toEqual([ + "matching", + ]); + await env.DB.prepare("UPDATE team_channel_bindings SET team_id = ? WHERE external_id = 'C1'") + .bind(other.id) + .run(); + expect((await channels.getSlackAutomationsForChannel("C1")).map((row) => row.id)).toEqual([ + "other", + ]); + }); }); diff --git a/packages/control-plane/test/integration/slack-notify.test.ts b/packages/control-plane/test/integration/slack-notify.test.ts index ee274e10a4..6d07bb2a21 100644 --- a/packages/control-plane/test/integration/slack-notify.test.ts +++ b/packages/control-plane/test/integration/slack-notify.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SELF, env } from "cloudflare:test"; import { IntegrationSettingsStore } from "../../src/db/integration-settings"; import { SessionIndexStore } from "../../src/db/session-index"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; import { cleanD1Tables } from "./cleanup"; import { initNamedSessionDO, queryDO, seedSandboxAuth } from "./helpers"; @@ -11,6 +12,8 @@ async function setupSession(opts?: { parentSessionId?: string | null; spawnSource?: "user" | "agent"; userId?: string; + ownerTeamId?: string | null; + visibility?: SessionVisibility; }) { const sessionName = `sess-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const { stub } = await initNamedSessionDO(sessionName, { @@ -29,8 +32,8 @@ async function setupSession(opts?: { const now = Date.now(); await sessionStore.create({ id: sessionName, - ownerTeamId: null, - visibility: "workspace", + ownerTeamId: opts?.ownerTeamId ?? null, + visibility: opts?.visibility ?? "workspace", title: "Test session", repoOwner: "acme", repoName: "web-app", @@ -62,9 +65,21 @@ async function setupSession(opts?: { function buildSlackFetchMock(handlers: { postMessage?: () => Response; getPermalink?: () => Response; + listChannels?: () => Response; }): ReturnType { return vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("conversations.list")) { + return handlers.listChannels + ? handlers.listChannels() + : Response.json({ + ok: true, + channels: [ + { id: "C1", name: "ops", is_member: true }, + { id: "C2", name: "nope", is_member: true }, + ], + }); + } if (url.includes("chat.postMessage")) { return handlers.postMessage ? handlers.postMessage() @@ -92,6 +107,84 @@ describe("POST /sessions/:id/slack-notify", () => { vi.unstubAllGlobals(); }); + it("does not post when the authoritative session is missing", async () => { + const { sessionName, sandboxToken } = await setupSession({ agentNotificationsEnabled: true }); + await env.DB.prepare("DELETE FROM sessions WHERE id = ?").bind(sessionName).run(); + const slackFetch = vi.fn(); + vi.stubGlobal("fetch", slackFetch); + + const res = await SELF.fetch(`https://test.local/sessions/${sessionName}/slack-notify`, { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ channel: "C1", text: "secret text" }), + }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_input" }); + expect(slackFetch).not.toHaveBeenCalled(); + }); + + it("refuses a private session through the sandbox-authenticated route", async () => { + const { sessionName, sandboxToken } = await setupSession({ + visibility: "private", + agentNotificationsEnabled: true, + }); + const slackFetch = vi.fn(); + vi.stubGlobal("fetch", slackFetch); + + const res = await SELF.fetch(`https://test.local/sessions/${sessionName}/slack-notify`, { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ channel: "#ops", text: "secret text" }), + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "session_scope_denied" }); + expect(slackFetch).not.toHaveBeenCalled(); + }); + + it.each(["team", "workspace"] as const)( + "refuses %s-visible cross-team posts after resolving names to channel IDs", + async (visibility) => { + for (const teamId of ["team-a", "team-b"]) { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, 1, 1)" + ) + .bind(teamId, teamId, teamId) + .run(); + } + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', 'team-b', 'source', 1)" + ).run(); + const { sessionName, sandboxToken } = await setupSession({ + ownerTeamId: "team-a", + visibility, + agentNotificationsEnabled: true, + }); + const channelName = `${visibility}-ops`; + const slackFetch = buildSlackFetchMock({ + listChannels: () => + Response.json({ ok: true, channels: [{ id: "C1", name: channelName }] }), + }); + vi.stubGlobal("fetch", slackFetch); + + for (const channel of ["C1", `#${channelName}`, channelName]) { + const res = await SELF.fetch(`https://test.local/sessions/${sessionName}/slack-notify`, { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ channel, text: "secret text" }), + }); + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "session_scope_denied" }); + } + + expect(slackFetch).toHaveBeenCalledOnce(); + for (const [input] of slackFetch.mock.calls) { + expect(String(input)).toContain("conversations.list"); + } + } + ); + it("returns 401 without sandbox auth", async () => { const { sessionName } = await setupSession({ agentNotificationsEnabled: true }); @@ -192,42 +285,4 @@ describe("POST /sessions/:id/slack-notify", () => { const body = await res.json<{ error: string }>(); expect(body.error).toBe("channel_not_found_or_forbidden"); }); - - it("passes channel verbatim to Slack — both name and ID forms", async () => { - const { sessionName, sandboxToken } = await setupSession({ - agentNotificationsEnabled: true, - }); - - let capturedChannel: string | undefined; - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("chat.postMessage")) { - const body = init?.body ? JSON.parse(init.body as string) : {}; - capturedChannel = body.channel as string; - return new Response(JSON.stringify({ ok: true, channel: "C1", ts: "1.2" }), { - status: 200, - }); - } - if (url.includes("chat.getPermalink")) { - return new Response( - JSON.stringify({ ok: true, permalink: "https://x.slack.com/p", channel: "C1" }), - { status: 200 } - ); - } - throw new Error(`Unmocked fetch: ${url}`); - }) - ); - - await SELF.fetch(`https://test.local/sessions/${sessionName}/slack-notify`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sandboxToken}`, - }, - body: JSON.stringify({ channel: "C01ABC", text: "hi" }), - }); - expect(capturedChannel).toBe("C01ABC"); - }); }); diff --git a/packages/control-plane/test/integration/slack-post-gates.test.ts b/packages/control-plane/test/integration/slack-post-gates.test.ts new file mode 100644 index 0000000000..36623b9d66 --- /dev/null +++ b/packages/control-plane/test/integration/slack-post-gates.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { env } from "cloudflare:test"; +import { verifyCallbackSignature } from "@open-inspect/shared/auth"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import { createCloudflareEnv } from "../../src/cloudflare/platform"; +import { createDurableObjectSessionPlatform } from "../../src/cloudflare/session-platform"; +import { createSessionRuntime } from "../../src/session/components"; +import { AutomationStore } from "../../src/db/automation-store"; +import { Scheduler } from "../../src/scheduler/scheduler"; +import { cleanD1Tables } from "./cleanup"; +import { initSession, seedActiveUser } from "./helpers"; +import { makeRunRow, seedRun } from "./run-helpers"; +import { runInSessionDO } from "./session-do-access"; + +const refusals: Array<{ + name: string; + visibility: SessionVisibility; + boundTeamId: string | null; + missing?: boolean; +}> = [ + { name: "missing session", visibility: "workspace", boundTeamId: null, missing: true }, + { name: "private session", visibility: "private", boundTeamId: null }, + { + name: "private session in its own team's channel", + visibility: "private", + boundTeamId: "team-a", + }, + { name: "team-visible cross-team session", visibility: "team", boundTeamId: "team-b" }, + { name: "workspace-visible cross-team session", visibility: "workspace", boundTeamId: "team-b" }, +]; + +async function setScope(sessionId: string, scope: (typeof refusals)[number]): Promise { + if (scope.missing) { + await env.DB.prepare("DELETE FROM sessions WHERE id = ?").bind(sessionId).run(); + } else { + await env.DB.prepare( + "UPDATE sessions SET owner_team_id = 'team-a', visibility = ? WHERE id = ?" + ) + .bind(scope.visibility, sessionId) + .run(); + } + if (scope.boundTeamId) { + await env.DB.prepare( + "INSERT INTO team_channel_bindings (provider, external_id, team_id, kind, created_at) VALUES ('slack', 'C1', ?, 'source', 1)" + ) + .bind(scope.boundTeamId) + .run(); + } +} + +async function expectSafeClosure(slackFetch: ReturnType, sessionId: string) { + expect(slackFetch).toHaveBeenCalledOnce(); + expect(slackFetch.mock.calls[0][0]).toBe("https://internal/callbacks/thread_closed"); + const body = JSON.parse(String(slackFetch.mock.calls[0][1]?.body)); + expect(body).toEqual({ + kind: "slack.thread_closed", + sessionId, + timestamp: expect.any(Number), + context: { channel: "C1", threadTs: "1700000000.000200" }, + signature: expect.any(String), + }); + expect(await verifyCallbackSignature(body, "outbound-test-secret")).toBe(true); +} + +describe("Slack outbound post gates (real D1)", () => { + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser("user-1"); + for (const teamId of ["team-a", "team-b"]) { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, 1, 1)" + ) + .bind(teamId, teamId, teamId) + .run(); + } + }); + + describe.each(["complete", "tool_call", "activity"] as const)("session callback: %s", (path) => { + it.each(refusals)("sends only a safe closure for $name", async (scope) => { + const { stub, sessionName } = await initSession(); + await setScope(sessionName, scope); + const slackFetch = vi.fn().mockResolvedValue(new Response("ok")); + const linearFetch = vi.fn(); + + await runInSessionDO(stub, async (_instance, state) => { + const author = state.storage.sql + .exec<{ id: string }>("SELECT id FROM participants LIMIT 1") + .one(); + state.storage.sql.exec( + "INSERT INTO messages (id, author_id, content, source, callback_context, status, created_at, started_at) VALUES ('msg-1', ?, 'secret prompt', 'slack', ?, 'processing', 1, 1)", + author.id, + JSON.stringify({ + channel: "C1", + threadTs: "1700000000.000200", + repoFullName: "secret/repository", + model: "secret-model", + }) + ); + // Build the production composition root against the actual D1 and DO SQLite. + const runtime = createSessionRuntime(createDurableObjectSessionPlatform(state, env.DB), { + ...createCloudflareEnv(env), + SLACK_BOT: { fetch: slackFetch }, + LINEAR_BOT: { fetch: linearFetch }, + SERVICE_AUTH_SECRET_SLACK_BOT: "outbound-test-secret", + SERVICE_AUTH_SECRET_LINEAR_BOT: "linear-test-secret", + }); + const callbacks = runtime.internals.callbackService; + if (path === "complete") { + await callbacks.notifyComplete("msg-1", false, "secret error"); + } else if (path === "tool_call") { + await callbacks.notifyToolCall("msg-1", { + type: "tool_call", + tool: "bash", + args: { command: "secret command" }, + callId: "call-1", + }); + } else { + await callbacks.refreshSlackActivity("msg-1", Date.now()); + } + }); + + await expectSafeClosure(slackFetch, sessionName); + expect(linearFetch).not.toHaveBeenCalled(); + }); + }); + + it.each(refusals)("scheduler sends only a safe closure for $name", async (scope) => { + const { sessionName } = await initSession(); + await setScope(sessionName, scope); + const store = new AutomationStore(env.DB); + await store.create({ + id: "auto-1", + owner_team_id: "team-a", + name: "Private automation name", + instructions: "Secret instructions", + trigger_type: "schedule", + schedule_cron: "0 9 * * *", + schedule_tz: "UTC", + harness: "opencode", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: "user-1", + created_at: 1, + updated_at: 1, + deleted_at: null, + event_type: null, + trigger_config: null, + trigger_auth_data: null, + }); + const run = makeRunRow("auto-1", { session_id: sessionName, status: "running" }); + await seedRun(run); + await env.DB.prepare("UPDATE automation_invocations SET trigger_metadata = ? WHERE id = ?") + .bind(JSON.stringify({ channel: "C1", messageTs: "1700000000.000200" }), run.invocation_id) + .run(); + const slackFetch = vi.fn().mockResolvedValue(new Response("ok")); + const linearFetch = vi.fn(); + const scheduler = new Scheduler( + env.DB, + { + ...createCloudflareEnv(env), + SLACK_BOT: { fetch: slackFetch }, + LINEAR_BOT: { fetch: linearFetch }, + SERVICE_AUTH_SECRET_SLACK_BOT: "outbound-test-secret", + SERVICE_AUTH_SECRET_LINEAR_BOT: "linear-test-secret", + }, + { submit() {} } + ); + + await scheduler.runComplete({ + automationId: "auto-1", + runId: run.id, + sessionId: sessionName, + messageId: "msg-1", + success: false, + error: "secret error", + }); + + expect((await store.getRunById("auto-1", run.id))?.status).toBe("failed"); + await expectSafeClosure(slackFetch, sessionName); + expect(linearFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/test/integration/team-channel-bindings.test.ts b/packages/control-plane/test/integration/team-channel-bindings.test.ts new file mode 100644 index 0000000000..d984c4ec44 --- /dev/null +++ b/packages/control-plane/test/integration/team-channel-bindings.test.ts @@ -0,0 +1,585 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { computeHmacHex } from "@open-inspect/shared/auth"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import type { TeamChannelBinding } from "@open-inspect/shared/types/team-channel-bindings"; +import { + TeamChannelBindingConflictError, + TeamChannelBindingStore, +} from "../../src/db/team-channel-bindings"; +import { IntegrationSettingsStore } from "../../src/db/integration-settings"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamStore } from "../../src/db/teams"; +import { UserStore } from "../../src/db/user-store"; +import type { SqlDatabase, SqlStatement } from "../../src/db/sql-database"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, serviceFetch, serviceRequestHeaders, sqlDatabase } from "./helpers"; + +const BASE = "https://test.local"; +const OWNER = "11111111111111111111111111111111"; +const SLACK_SERVICE_SECRET = "test-channel-info-service-secret"; +const actor = { actorUserId: OWNER, requestId: "binding-request" }; + +async function createTeam(slug: string) { + return new TeamStore(env.DB).create({ slug, name: slug, joinPolicy: "invite_only" }); +} + +function slackBinding(teamId: string, kind: TeamChannelBinding["kind"]): TeamChannelBinding { + return { provider: "slack", externalId: "C123", teamId, kind }; +} + +async function bindingAudits(teamId: string) { + const rows = await env.DB.prepare( + `SELECT action, actor_user_id_snapshot, team_id, metadata_json + FROM authorization_audit_events + WHERE team_id = ? AND action IN ('team.binding_added', 'team.binding_removed') + ORDER BY occurred_at, id` + ) + .bind(teamId) + .all<{ + action: string; + actor_user_id_snapshot: string; + team_id: string; + metadata_json: string; + }>(); + return rows.results; +} + +beforeEach(async () => { + await cleanD1Tables(); + await serviceFetch(`${BASE}/me/authorization`); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("team channel binding store", () => { + it("gets provider-keyed bindings and lists only the requested team", async () => { + const team = await createTeam("engineering"); + const other = await createTeam("other"); + const store = new TeamChannelBindingStore(env.DB); + expect(await store.get("slack", "C123")).toBeNull(); + const slack = slackBinding(team.id, "primary"); + const linear: TeamChannelBinding = { ...slack, provider: "linear", kind: "source" }; + await store.put(slack, actor); + await store.put(linear, actor); + await store.put({ ...slack, externalId: "C456", teamId: other.id }, actor); + expect(await store.get("slack", "C123")).toEqual(slack); + expect(await store.get("linear", "C123")).toEqual(linear); + expect(await store.listByTeam(team.id)).toEqual([linear, slack]); + }); + + it("rejects cross-team rebinding and a second primary without auditing failed writes", async () => { + const team = await createTeam("engineering"); + const other = await createTeam("other"); + const store = new TeamChannelBindingStore(env.DB); + const binding = slackBinding(team.id, "primary"); + await store.put(binding, actor); + await expect(store.put({ ...binding, teamId: other.id }, actor)).rejects.toBeInstanceOf( + TeamChannelBindingConflictError + ); + await expect(store.put({ ...binding, externalId: "C456" }, actor)).rejects.toBeInstanceOf( + TeamChannelBindingConflictError + ); + expect(await store.get("slack", "C123")).toEqual(binding); + expect(await store.get("slack", "C456")).toBeNull(); + expect(await bindingAudits(other.id)).toEqual([]); + expect(await bindingAudits(team.id)).toHaveLength(1); + }); + + it("updates kind, permits a replacement primary, and audits only applied mutations", async () => { + const team = await createTeam("engineering"); + const store = new TeamChannelBindingStore(env.DB); + const binding = slackBinding(team.id, "primary"); + await store.put(binding, actor); + await store.put(binding, actor); + const created = await bindingAudits(team.id); + expect(created).toHaveLength(1); + expect(JSON.parse(created[0]!.metadata_json)).toEqual({ + before: {}, + requested: {}, + after: binding, + }); + await store.put({ ...binding, kind: "source" }, actor); + await store.put({ ...binding, externalId: "C456" }, actor); + await expect(store.put(binding, actor)).rejects.toBeInstanceOf(TeamChannelBindingConflictError); + expect((await store.get("slack", "C123"))?.kind).toBe("source"); + expect(await store.remove(team.id, "slack", "C123", actor)).toBe(true); + expect(await store.remove(team.id, "slack", "C123", actor)).toBe(false); + const events = await bindingAudits(team.id); + expect(events.filter((event) => event.action === "team.binding_added")).toHaveLength(3); + expect(events.map((event) => JSON.parse(event.metadata_json))).toContainEqual({ + before: binding, + requested: {}, + after: { ...binding, kind: "source" }, + }); + const removed = events.find((event) => event.action === "team.binding_removed")!; + expect(removed.actor_user_id_snapshot).toBe(OWNER); + expect(JSON.parse(removed.metadata_json)).toEqual({ + before: { ...binding, kind: "source" }, + requested: {}, + after: {}, + }); + }); + + it("does not let a team remove another team's binding", async () => { + const team = await createTeam("engineering"); + const other = await createTeam("other"); + const store = new TeamChannelBindingStore(env.DB); + await store.put( + { provider: "slack", externalId: "C123", teamId: team.id, kind: "source" }, + actor + ); + expect(await store.remove(other.id, "slack", "C123", actor)).toBe(false); + expect(await store.get("slack", "C123")).not.toBeNull(); + expect(await bindingAudits(other.id)).toEqual([]); + }); + + it("allows only one winner when teams concurrently bind the same channel", async () => { + const first = await createTeam("first"); + const second = await createTeam("second"); + const store = new TeamChannelBindingStore(env.DB); + const results = await Promise.allSettled( + [first, second].map((team) => + store.put({ provider: "slack", externalId: "C123", teamId: team.id, kind: "source" }, actor) + ) + ); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.find((result) => result.status === "rejected"); + expect(rejected?.status === "rejected" ? rejected.reason : null).toBeInstanceOf( + TeamChannelBindingConflictError + ); + const winner = await store.get("slack", "C123"); + expect(await bindingAudits(winner!.teamId)).toHaveLength(1); + const loser = winner!.teamId === first.id ? second : first; + expect(await bindingAudits(loser.id)).toEqual([]); + }); + + it("rolls back creation, kind changes, and deletion when audit insertion fails", async () => { + const team = await createTeam("engineering"); + const db = sqlDatabase(env.DB); + const failAudit: SqlDatabase = { + prepare(sql) { + return sql.includes("INSERT INTO authorization_audit_events") + ? db.prepare("INSERT INTO authorization_audit_events (id) VALUES (?)").bind("bad-audit") + : db.prepare(sql); + }, + batch(statements: SqlStatement[]) { + return db.batch(statements); + }, + }; + const binding = slackBinding(team.id, "source"); + const failing = new TeamChannelBindingStore(failAudit); + const store = new TeamChannelBindingStore(env.DB); + await expect(failing.put(binding, actor)).rejects.toThrow(); + expect(await store.get("slack", "C123")).toBeNull(); + await store.put(binding, actor); + await expect(failing.put({ ...binding, kind: "primary" }, actor)).rejects.toThrow(); + expect(await store.get("slack", "C123")).toEqual(binding); + await expect(failing.remove(team.id, "slack", "C123", actor)).rejects.toThrow(); + expect(await store.get("slack", "C123")).toEqual(binding); + expect(await bindingAudits(team.id)).toHaveLength(1); + }); +}); + +describe("team channel binding routes", () => { + const channelInfo = { id: "C123", name: "engineering", isMember: true, isExtShared: false }; + + async function request( + path: string, + method = "GET", + body?: object, + overrides: object = {}, + slackFetch = vi.fn().mockResolvedValue(Response.json(channelInfo)) + ) { + const url = `${BASE}${path}`; + const raw = body === undefined ? undefined : JSON.stringify(body); + const headers = await serviceRequestHeaders(url, { method, body: raw }); + const bindings = { + ...env, + SERVICE_AUTH_SECRET_SLACK_BOT: SLACK_SERVICE_SECRET, + SLACK_BOT: { fetch: slackFetch } as unknown as Fetcher, + ...overrides, + }; + return routeRequest( + new Request(url, { method, headers, body: raw }), + bindings, + createExecutionContext() + ); + } + + it("validates membership through a signed service binding and lists/deletes bindings", async () => { + const team = await createTeam("engineering"); + const path = `/teams/${team.id}/channel-bindings`; + const fetch = vi.fn().mockResolvedValue(Response.json(channelInfo)); + const response = await request(`${path}/slack/C123`, "PUT", { kind: "primary" }, {}, fetch); + expect(response.status).toBe(200); + const binding = { provider: "slack", externalId: "C123", teamId: team.id, kind: "primary" }; + expect(await response.json()).toEqual({ binding }); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("https://internal/internal/channel-info"); + expect(init.method).toBe("POST"); + const { signature, ...payload } = JSON.parse(init.body); + expect(payload).toEqual({ channelId: "C123", timestamp: expect.any(Number) }); + expect(signature).toBe(await computeHmacHex(JSON.stringify(payload), SLACK_SERVICE_SECRET)); + const list = await request(path); + expect(list.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await list.json()).toEqual({ bindings: [binding] }); + expect((await request(`${path}/slack/C123`, "DELETE")).status).toBe(204); + expect((await request(`${path}/slack/C123`, "DELETE")).status).toBe(204); + expect(await (await request(path)).json()).toEqual({ bindings: [] }); + }); + + it.each([ + { ...channelInfo, isMember: false }, + { ...channelInfo, isExtShared: true }, + { id: "C123", name: "engineering" }, + { ...channelInfo, id: "C456" }, + null, + ])("fails closed for nonjoinable or absent channel information %j", async (info) => { + const team = await createTeam("engineering"); + const fetch = vi.fn().mockResolvedValue(Response.json(info)); + const response = await request( + `/teams/${team.id}/channel-bindings/slack/C123`, + "PUT", + { kind: "source" }, + {}, + fetch + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "channel_not_joinable" }); + expect(await new TeamChannelBindingStore(env.DB).listByTeam(team.id)).toEqual([]); + expect(await bindingAudits(team.id)).toEqual([]); + }); + + it("rejects unsuccessful channel lookups and distinguishes unavailable service configuration", async () => { + const team = await createTeam("engineering"); + const path = `/teams/${team.id}/channel-bindings/slack/C123`; + for (const fetch of [ + vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + vi.fn().mockResolvedValue(new Response("invalid JSON")), + ]) { + const response = await request(path, "PUT", { kind: "source" }, {}, fetch); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "channel_not_joinable" }); + } + for (const overrides of [ + { SLACK_BOT: undefined }, + { SERVICE_AUTH_SECRET_SLACK_BOT: undefined }, + ]) { + const response = await request(path, "PUT", { kind: "source" }, overrides); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ code: "channel_info_unavailable" }); + } + const unavailable = await request( + path, + "PUT", + { kind: "source" }, + {}, + vi.fn().mockRejectedValue(new Error("offline")) + ); + expect(unavailable.status).toBe(503); + expect(await new TeamChannelBindingStore(env.DB).listByTeam(team.id)).toEqual([]); + }); + + it("requires canManageBindings for every Team binding route, independent of enforcement mode", async () => { + const team = await createTeam("engineering"); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY.member.id, OWNER) + .run(); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + const fetch = vi.fn().mockResolvedValue(Response.json(channelInfo)); + const slackFetch = vi.fn(); + vi.stubGlobal("fetch", slackFetch); + for (const mode of ["off", "shadow", "on"]) { + for (const method of ["GET", "PUT", "DELETE"]) { + const path = `/teams/${team.id}/channel-bindings${method === "GET" ? "" : "/slack/C123"}`; + const denied = await request( + path, + method, + method === "PUT" ? { kind: "source" } : undefined, + { TEAMS_ENFORCEMENT: mode }, + fetch + ); + expect(denied.status).toBe(403); + } + for (const token of ["xoxb-test", undefined]) { + const denied = await request(`/teams/${team.id}/slack-channels`, "GET", undefined, { + TEAMS_ENFORCEMENT: mode, + SLACK_BOT_TOKEN: token, + }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ code: "team_capability_required" }); + } + } + expect(fetch).not.toHaveBeenCalled(); + expect(slackFetch).not.toHaveBeenCalled(); + await new TeamMembershipStore(env.DB).setRole(team.id, OWNER, "lead"); + expect((await request(`/teams/${team.id}/channel-bindings`)).status).toBe(200); + expect( + (await request(`/teams/${team.id}/channel-bindings/slack/C123`, "PUT", { kind: "source" })) + .status + ).toBe(200); + const actorless = await serviceFetch(`${BASE}/teams/${team.id}/channel-bindings`, { + service: "slack-bot", + }); + expect(actorless.status).toBe(403); + expect( + (await serviceFetch(`${BASE}/teams/${team.id}/slack-channels`, { service: "slack-bot" })) + .status + ).toBe(403); + }); + + it("lists Slack channel names for a team lead without automation permissions", async () => { + const team = await createTeam("engineering"); + const otherTeam = await createTeam("other"); + const store = new TeamChannelBindingStore(env.DB); + await store.put({ ...slackBinding(team.id, "source"), externalId: "C_OWN" }, actor); + await store.put({ ...slackBinding(otherTeam.id, "source"), externalId: "C_OTHER" }, actor); + await new TeamMembershipStore(env.DB).add(team.id, OWNER, "lead"); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles (id, key, name, normalized_name, description, is_system) + VALUES ('binding_lead', NULL, 'Binding Lead', 'binding lead', NULL, 0)` + ), + env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'binding_lead' WHERE user_id = ?" + ).bind(OWNER), + ]); + const slackFetch = vi.fn().mockImplementation(async () => + Response.json({ + ok: true, + channels: [ + { id: "C123", name: "engineering", is_private: false, is_member: true }, + { id: "C_OWN", name: "own-private", is_private: true, is_member: true }, + { id: "C_OTHER", name: "other-private", is_private: true, is_member: true }, + { id: "C_UNBOUND", name: "unbound-private", is_private: true, is_member: true }, + ], + }) + ); + vi.stubGlobal("fetch", slackFetch); + expect((await request("/integration-settings/slack/channels")).status).toBe(403); + expect(slackFetch).not.toHaveBeenCalled(); + const response = await request(`/teams/${team.id}/slack-channels`); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await response.json()).toEqual({ + channels: [ + { id: "C123", name: "engineering", isPrivate: false, isMember: true }, + { id: "C_OWN", name: "own-private", isPrivate: true, isMember: true }, + ], + }); + expect(slackFetch).toHaveBeenCalledOnce(); + expect(slackFetch.mock.calls[0]?.[0]).toContain("https://slack.com/api/conversations.list"); + await env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES ('binding_lead', 'automations.read')" + ).run(); + const globalReader = await request(`/teams/${team.id}/slack-channels`); + expect(await globalReader.json()).toMatchObject({ + channels: expect.arrayContaining([ + expect.objectContaining({ id: "C_OTHER" }), + expect.objectContaining({ id: "C_UNBOUND" }), + ]), + }); + }); + + it("returns conflicts without disclosing another team's identity", async () => { + const team = await createTeam("engineering"); + const other = await createTeam("other"); + const store = new TeamChannelBindingStore(env.DB); + await store.put( + { provider: "slack", externalId: "C123", teamId: other.id, kind: "source" }, + actor + ); + const response = await request(`/teams/${team.id}/channel-bindings/slack/C123`, "PUT", { + kind: "source", + }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Channel binding conflicts with an existing binding", + code: "channel_binding_conflict", + }); + expect(await bindingAudits(team.id)).toEqual([]); + }); + + it("rejects unsupported providers and invalid kinds before checking Slack", async () => { + const team = await createTeam("engineering"); + const fetch = vi.fn(); + for (const provider of ["github", "linear"]) { + expect( + ( + await request( + `/teams/${team.id}/channel-bindings/${provider}/C123`, + "PUT", + { kind: "source" }, + {}, + fetch + ) + ).status + ).toBe(400); + } + expect( + ( + await request( + `/teams/${team.id}/channel-bindings/slack/C123`, + "PUT", + { kind: "other" }, + {}, + fetch + ) + ).status + ).toBe(400); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("service channel binding lookup", () => { + it("allows unbound DMs under reject policy without exempting regular channels", async () => { + const unbound = await serviceFetch(`${BASE}/channel-bindings/slack/C123`, { + service: "slack-bot", + }); + expect(unbound.status).toBe(200); + expect(await unbound.json()).toEqual({ teamId: null }); + await new IntegrationSettingsStore(env.DB).setGlobal("slack", { + defaults: { unboundChannels: "reject" }, + }); + const dm = await serviceFetch(`${BASE}/channel-bindings/slack/D123`, { service: "slack-bot" }); + expect(dm.status).toBe(200); + expect(await dm.json()).toEqual({ teamId: null }); + for (const channel of ["C123", "G123", "Dinvalid"]) { + const rejected = await serviceFetch(`${BASE}/channel-bindings/slack/${channel}`, { + service: "slack-bot", + }); + expect(rejected.status).toBe(404); + expect(await rejected.json()).toEqual({ + error: "Channel is not bound", + code: "channel_unbound", + }); + } + }); + + it("preserves any explicit DM binding rather than bypassing its team scope", async () => { + const team = await createTeam("engineering"); + await new TeamChannelBindingStore(env.DB).put( + { provider: "slack", externalId: "D123", teamId: team.id, kind: "source" }, + actor + ); + await new IntegrationSettingsStore(env.DB).setGlobal("slack", { + defaults: { unboundChannels: "reject" }, + }); + const dm = await serviceFetch(`${BASE}/channel-bindings/slack/D123`, { service: "slack-bot" }); + expect(dm.status).toBe(200); + expect(await dm.json()).toEqual({ teamId: team.id, kind: "source" }); + }); + + it("round-trips unboundChannels through the settings API and rejects invalid or repo-scoped policies", async () => { + const endpoint = `${BASE}/integration-settings/slack`; + for (const unboundChannels of ["workspace", "reject"]) { + const updated = await serviceFetch(endpoint, { + method: "PUT", + body: JSON.stringify({ settings: { defaults: { unboundChannels } } }), + }); + expect(updated.status).toBe(200); + const settings = await serviceFetch(endpoint, { service: "slack-bot" }); + expect(await settings.json()).toEqual({ + integrationId: "slack", + settings: { defaults: { unboundChannels } }, + }); + } + expect( + ( + await serviceFetch(endpoint, { + method: "PUT", + body: JSON.stringify({ settings: { defaults: { unboundChannels: "team" } } }), + }) + ).status + ).toBe(400); + expect( + ( + await serviceFetch(`${endpoint}/repos/acme/widgets`, { + method: "PUT", + body: JSON.stringify({ settings: { unboundChannels: "workspace" } }), + }) + ).status + ).toBe(400); + expect( + (await new IntegrationSettingsStore(env.DB).getGlobal("slack"))?.defaults?.unboundChannels + ).toBe("reject"); + }); + + it("grants actorless lookup only to slack-bot and rejects unsupported providers", async () => { + const team = await createTeam("engineering"); + await new TeamChannelBindingStore(env.DB).put(slackBinding(team.id, "primary"), actor); + await new IntegrationSettingsStore(env.DB).setGlobal("slack", { + defaults: { unboundChannels: "reject" }, + }); + const response = await serviceFetch(`${BASE}/channel-bindings/slack/C123`, { + service: "slack-bot", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ teamId: team.id, kind: "primary" }); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + for (const service of ["github-bot", "linear-bot"] as const) { + expect((await serviceFetch(`${BASE}/channel-bindings/slack/C123`, { service })).status).toBe( + 403 + ); + } + for (const provider of ["linear", "github"]) { + const unsupported = await serviceFetch(`${BASE}/channel-bindings/${provider}/C123`, { + service: "slack-bot", + }); + expect(unsupported.status).toBe(400); + expect(await unsupported.json()).toEqual({ error: "Unsupported channel binding provider" }); + } + }); + + it("denies human owners and custom-role integration readers", async () => { + const team = await createTeam("engineering"); + await new TeamChannelBindingStore(env.DB).put( + { provider: "slack", externalId: "C123", teamId: team.id, kind: "source" }, + actor + ); + const endpoint = `${BASE}/channel-bindings/slack/C123`; + const owner = await serviceFetch(endpoint); + expect(owner.status).toBe(403); + expect(await owner.json()).toMatchObject({ code: "service_capability_required" }); + + const roleId = "role_channel_binding_integration_reader"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, 'Integration Reader', 'integration reader', NULL, 0)` + ).bind(roleId), + env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'integrations.read')" + ).bind(roleId), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + roleId, + OWNER + ), + ]); + expect((await serviceFetch(`${BASE}/integration-settings/slack`)).status).toBe(200); + const reader = await serviceFetch(endpoint); + expect(reader.status).toBe(403); + expect(await reader.json()).toMatchObject({ code: "service_capability_required" }); + }); + + it.each([ + { service: "github-bot", provider: "github", providerUserId: "208" }, + { service: "linear-bot", provider: "linear", providerUserId: "binding-reader" }, + ] as const)( + "denies $service even with an owner actor", + async ({ service, provider, providerUserId }) => { + const team = await createTeam("engineering"); + await new TeamChannelBindingStore(env.DB).put( + { provider: "slack", externalId: "C123", teamId: team.id, kind: "source" }, + actor + ); + await new UserStore(env.DB).createIdentity({ userId: OWNER, provider, providerUserId }); + const denied = await serviceFetch(`${BASE}/channel-bindings/slack/C123`, { + service, + actor: `${provider}:${providerUserId}`, + }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ code: "service_capability_required" }); + } + ); +}); diff --git a/packages/control-plane/test/integration/team-grants.test.ts b/packages/control-plane/test/integration/team-grants.test.ts index a5a3f80016..a1dcbe0ec8 100644 --- a/packages/control-plane/test/integration/team-grants.test.ts +++ b/packages/control-plane/test/integration/team-grants.test.ts @@ -1,13 +1,22 @@ import { createExecutionContext, env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { teamChannelBindingSchema } from "@open-inspect/shared/types/team-channel-bindings"; +import { AuthorizationStore } from "../../src/db/authorization-store"; +import { TeamChannelBindingStore } from "../../src/db/team-channel-bindings"; import { TeamStore } from "../../src/db/teams"; import { TeamMembershipStore } from "../../src/db/team-memberships"; import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; import { EnvironmentStore } from "../../src/db/environments"; +import { UserStore } from "../../src/db/user-store"; import { REPOS_CACHE_KEY, reposCacheIdentity } from "../../src/routes/repos"; import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; import { cleanD1Tables } from "./cleanup"; -import { routeRequest, seedActiveUser, serviceRequestHeaders } from "./helpers"; +import { + routeRequest, + seedActiveUser, + serviceRequestHeaders, + type ServiceRequestInit, +} from "./helpers"; const MEMBER = "22222222222222222222222222222222"; const OTHER = "33333333333333333333333333333333"; @@ -37,12 +46,36 @@ async function request(path: string, method = "GET", body?: object, userId?: str ); } +async function slackCatalog( + path: string, + query = "channel=slack:C-CATALOG", + init: ServiceRequestInit = {} +) { + const url = `https://test.local${path}${query ? `?${query}` : ""}`; + return routeRequest( + new Request(url, { + headers: await serviceRequestHeaders(url, { + service: "slack-bot", + actor: "slack:U-CATALOG", + ...init, + }), + }), + env, + createExecutionContext() + ); +} + describe("team repository grants", () => { let teamId: string; beforeEach(async () => { await cleanD1Tables(); await seedActiveUser(MEMBER); await seedActiveUser(OTHER); + await new UserStore(env.DB).createIdentity({ + userId: MEMBER, + provider: "slack", + providerUserId: "U-CATALOG", + }); await request("/me/authorization"); teamId = ( await new TeamStore(env.DB).create({ @@ -96,6 +129,237 @@ describe("team repository grants", () => { }); }); + it("admits signed Slack channel catalogs and rechecks grants and membership", async () => { + await new TeamChannelBindingStore(env.DB).put( + { provider: "slack", externalId: "C-CATALOG", teamId, kind: "source" }, + { requestId: "catalog-binding", actorUserId: MEMBER } + ); + const grants = new TeamRepositoryGrantStore(env.DB); + const grant = await grants.add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + await new EnvironmentStore(env.DB).create( + { + id: "env_slack_catalog", + owner_team_id: teamId, + name: "Slack catalog", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: "repo-1", repo_id: 1, base_branch: "main" }] + ); + const repoList = await slackCatalog("/repos"); + expect(repoList.status).toBe(200); + expect(await repoList.json()).toMatchObject({ repos: [repos[0]] }); + const environmentList = await slackCatalog("/environments"); + expect(environmentList.status).toBe(200); + expect(await environmentList.json()).toMatchObject({ + environments: [expect.objectContaining({ id: "env_slack_catalog" })], + }); + await grants.remove(teamId, grant.id); + expect(await (await slackCatalog("/repos")).json()).toMatchObject({ + repos: [], + teamHasRepositoryGrants: false, + }); + expect(await (await slackCatalog("/environments")).json()).toMatchObject({ environments: [] }); + await new TeamMembershipStore(env.DB).remove(teamId, MEMBER); + const cache = vi.spyOn(env.REPOS_CACHE, "get"); + const scm = vi.spyOn(GitHubSourceControlProvider.prototype, "listRepositories"); + const environments = vi.spyOn(EnvironmentStore.prototype, "list"); + for (const path of ["/repos", "/environments"]) { + expect((await slackCatalog(path)).status).toBe(404); + } + expect(cache).not.toHaveBeenCalled(); + expect(scm).not.toHaveBeenCalled(); + expect(environments).not.toHaveBeenCalled(); + }); + + it("uses live repo bindings despite old or dual membership and retains unbound workspace reads", async () => { + const otherTeam = await new TeamStore(env.DB).create({ + slug: "other", + name: "Other", + joinPolicy: "invite_only", + }); + const bindings = new TeamChannelBindingStore(env.DB); + const bindingActor = { requestId: "catalog-binding", actorUserId: MEMBER }; + await bindings.put( + { provider: "slack", externalId: "C-CATALOG", teamId, kind: "source" }, + bindingActor + ); + const grants = new TeamRepositoryGrantStore(env.DB); + for (const [id, repoId] of [ + [teamId, 1], + [otherTeam.id, 2], + ] as const) { + await grants.add(id, { + kind: "repository", + repoExternalId: repoId, + owner: "acme", + name: `repo-${repoId}`, + }); + } + expect(await (await slackCatalog("/repos")).json()).toMatchObject({ repos: [repos[0]] }); + await bindings.remove(teamId, "slack", "C-CATALOG", bindingActor); + await bindings.put( + { provider: "slack", externalId: "C-CATALOG", teamId: otherTeam.id, kind: "source" }, + bindingActor + ); + const cache = vi.spyOn(env.REPOS_CACHE, "get"); + expect((await slackCatalog("/repos")).status).toBe(404); + expect(cache).not.toHaveBeenCalled(); + await new TeamMembershipStore(env.DB).add(otherTeam.id, MEMBER); + expect(await (await slackCatalog("/repos")).json()).toMatchObject({ repos: [repos[1]] }); + await bindings.remove(otherTeam.id, "slack", "C-CATALOG", bindingActor); + expect(await (await slackCatalog("/repos")).json()).toMatchObject({ repos }); + expect(await (await slackCatalog("/repos", "", { actor: undefined })).json()).toMatchObject({ + repos, + }); + }); + + it("refuses invalid, actorless, and other-service channel claims before reading catalogs", async () => { + await new UserStore(env.DB).createIdentity({ + userId: MEMBER, + provider: "linear", + providerUserId: "L-CATALOG", + }); + const binding = vi.spyOn(TeamChannelBindingStore.prototype, "get"); + const cache = vi.spyOn(env.REPOS_CACHE, "get"); + const scm = vi.spyOn(GitHubSourceControlProvider.prototype, "listRepositories"); + const environments = vi.spyOn(EnvironmentStore.prototype, "list"); + for (const path of ["/repos", "/environments"]) { + for (const query of [ + "channel", + "channel=slack:", + "channel=slack:C-CATALOG:extra", + "channel=slack:C%20CATALOG", + "channel=unknown:C-CATALOG", + "channel=linear:C-CATALOG", + "channel=slack:C-CATALOG&channel=slack:C-CATALOG", + "channel=slack:C-CATALOG&channel=slack:C-OTHER", + `channel=slack:C-CATALOG&teamId=${teamId}`, + `channel=slack:C-CATALOG&teamId=&teamId=${teamId}`, + `teamId=${teamId}&teamId=${teamId}`, + ]) { + expect((await slackCatalog(path, query)).status, `${path}?${query}`).toBe(400); + } + expect((await slackCatalog(path, undefined, { actor: undefined })).status).toBe(403); + expect((await slackCatalog(path, `teamId=${teamId}`)).status).toBe(404); + expect((await slackCatalog(path, `teamId=${teamId}`, { actor: undefined })).status).toBe(404); + expect( + (await slackCatalog(path, undefined, { service: "linear-bot", actor: undefined })).status + ).toBe(403); + expect( + (await slackCatalog(path, undefined, { service: "linear-bot", actor: "linear:L-CATALOG" })) + .status + ).toBe(403); + expect((await request(`${path}?channel=slack:C-CATALOG`)).status).toBe(403); + } + expect(binding).not.toHaveBeenCalled(); + expect(cache).not.toHaveBeenCalled(); + expect(scm).not.toHaveBeenCalled(); + expect(environments).not.toHaveBeenCalled(); + }); + + it("cryptographically rejects removed, replaced, or extended channel queries", async () => { + const binding = vi.spyOn(TeamChannelBindingStore.prototype, "get"); + const cache = vi.spyOn(env.REPOS_CACHE, "get"); + const scm = vi.spyOn(GitHubSourceControlProvider.prototype, "listRepositories"); + const environments = vi.spyOn(EnvironmentStore.prototype, "list"); + for (const path of ["/repos", "/environments"]) { + const url = `https://test.local${path}`; + const headers = await serviceRequestHeaders(`${url}?channel=slack:C-CATALOG`, { + service: "slack-bot", + actor: "slack:U-CATALOG", + }); + for (const query of [ + "", + "?channel=slack:C-OTHER", + "?channel=slack:C-CATALOG&channel=slack:C-OTHER", + `?channel=slack:C-CATALOG&teamId=${teamId}`, + ]) { + const response = await routeRequest( + new Request(`${url}${query}`, { headers }), + env, + createExecutionContext() + ); + expect(response.status).toBe(401); + } + } + expect(binding).not.toHaveBeenCalled(); + expect(cache).not.toHaveBeenCalled(); + expect(scm).not.toHaveBeenCalled(); + expect(environments).not.toHaveBeenCalled(); + }); + + it("fails closed on catalog authority errors or malformed bindings before catalog reads", async () => { + await new TeamChannelBindingStore(env.DB).put( + { provider: "slack", externalId: "C-CATALOG", teamId, kind: "source" }, + { requestId: "catalog-binding", actorUserId: MEMBER } + ); + const cache = vi.spyOn(env.REPOS_CACHE, "get"); + const scm = vi.spyOn(GitHubSourceControlProvider.prototype, "listRepositories"); + const environments = vi.spyOn(EnvironmentStore.prototype, "list"); + const repositories = vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironmentIds"); + for (const read of [ + vi.spyOn(TeamChannelBindingStore.prototype, "get"), + vi.spyOn(TeamStore.prototype, "isActive"), + vi.spyOn(TeamMembershipStore.prototype, "listForUser"), + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"), + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization"), + ]) { + read.mockRejectedValue(new Error("Authority unavailable")); + for (const path of ["/repos", "/environments"]) { + expect((await slackCatalog(path)).status, path).toBe(503); + } + read.mockRestore(); + } + vi.spyOn(TeamChannelBindingStore.prototype, "get").mockImplementation(async () => + teamChannelBindingSchema.parse({ + provider: "slack", + externalId: "C-CATALOG", + teamId, + kind: "invalid", + }) + ); + for (const path of ["/repos", "/environments"]) { + expect((await slackCatalog(path)).status, `malformed ${path}`).toBe(503); + } + expect(cache).not.toHaveBeenCalled(); + expect(scm).not.toHaveBeenCalled(); + expect(environments).not.toHaveBeenCalled(); + expect(repositories).not.toHaveBeenCalled(); + }); + + it.each(["suspended", "unassigned"])( + "rejects a %s canonical Slack actor even in an unbound channel before catalog reads", + async (state) => { + if (state === "suspended") { + await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(MEMBER).run(); + } else { + await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?") + .bind(MEMBER) + .run(); + } + const binding = vi.spyOn(TeamChannelBindingStore.prototype, "get"); + const cache = vi.spyOn(env.REPOS_CACHE, "get"); + const scm = vi.spyOn(GitHubSourceControlProvider.prototype, "listRepositories"); + const environments = vi.spyOn(EnvironmentStore.prototype, "list"); + for (const path of ["/repos", "/environments"]) { + expect((await slackCatalog(path)).status).toBe(403); + } + expect(binding).not.toHaveBeenCalled(); + expect(cache).not.toHaveBeenCalled(); + expect(scm).not.toHaveBeenCalled(); + expect(environments).not.toHaveBeenCalled(); + } + ); + it("identifies a team with no grants without falling back to the installation list", async () => { expect(await (await request(`/repos?teamId=${teamId}`)).json()).toMatchObject({ repos: [], diff --git a/packages/control-plane/test/smoke/run-smoke.mjs b/packages/control-plane/test/smoke/run-smoke.mjs index b932cb9461..317fe3abd0 100644 --- a/packages/control-plane/test/smoke/run-smoke.mjs +++ b/packages/control-plane/test/smoke/run-smoke.mjs @@ -29,6 +29,7 @@ const BRIDGE_REPLY = process.env.BRIDGE_REPLY ?? "Acknowledged by the smoke brid */ const ACTOR = "slack:U-COMPOSE-SMOKE"; const ACTOR_EMAIL = "compose-smoke@open-inspect.test"; +const CHANNEL_SCOPE = "slack:CCOMPOSESMOKE"; /** Budgets for the two waits that depend on the container doing real work. */ const PROMPT_ROUND_TRIP_TIMEOUT_MS = 60_000; @@ -198,11 +199,14 @@ async function attachmentRoundTrip(sessionId) { // Serialized once so the signature covers the exact multipart bytes sent. const encoded = new Request("http://smoke.invalid/", { method: "POST", body: form }); const body = new Uint8Array(await encoded.arrayBuffer()); - const upload = await signedRequest(`/sessions/${sessionId}/attachments`, { - method: "POST", - body, - headers: { "Content-Type": encoded.headers.get("Content-Type") }, - }); + const upload = await signedRequest( + `/sessions/${sessionId}/attachments?channel=${encodeURIComponent(CHANNEL_SCOPE)}`, + { + method: "POST", + body, + headers: { "Content-Type": encoded.headers.get("Content-Type") }, + } + ); const uploaded = await upload.json().catch(() => null); if (upload.status !== 201) fail(`attachment upload returned ${upload.status}`, uploaded); if (!uploaded?.attachmentId) fail("attachment upload returned no attachmentId", uploaded); @@ -250,10 +254,13 @@ async function main() { pass("client socket subscribed"); const promptContent = "Say hello from the compose smoke."; - const prompt = await signedFetch(`/sessions/${sessionId}/prompt`, { - method: "POST", - body: { content: promptContent }, - }); + const prompt = await signedFetch( + `/sessions/${sessionId}/prompt?channel=${encodeURIComponent(CHANNEL_SCOPE)}`, + { + method: "POST", + body: { content: promptContent }, + } + ); if (prompt.status !== 200 && prompt.status !== 202) { fail(`prompt returned ${prompt.status}`, prompt.body); } diff --git a/packages/sandbox-runtime/src/sandbox_runtime/harness/claude_tools.py b/packages/sandbox-runtime/src/sandbox_runtime/harness/claude_tools.py index 2d1370ae19..cddadd855f 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/harness/claude_tools.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/harness/claude_tools.py @@ -47,6 +47,7 @@ _SLACK_REASON_GUIDANCE: Final = { "feature_unavailable": "The deployment is not configured to send agent notifications. Tell the user this is unavailable.", "feature_disabled": "Agent notifications are disabled for this repository. Ask the user to enable them in integration settings.", + "session_scope_denied": "This session cannot post to this channel because of its visibility or team ownership. Do not retry in another channel without the user's permission.", "channel_not_found_or_forbidden": "The channel was not found, is archived, or the bot is not in it. If the channel name is correct and not archived, ask the user to invite the bot.", "empty_message_after_sanitization": "The message body was empty after sanitization. Try again with non-empty content.", "rate_limited": "Slack rate-limited the request. Wait before retrying.", diff --git a/packages/sandbox-runtime/src/sandbox_runtime/tools/slack-notify.js b/packages/sandbox-runtime/src/sandbox_runtime/tools/slack-notify.js index 57fecb185d..7a79f48d84 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/tools/slack-notify.js +++ b/packages/sandbox-runtime/src/sandbox_runtime/tools/slack-notify.js @@ -12,6 +12,8 @@ const REASON_GUIDANCE = { "The deployment is not configured to send agent notifications. Tell the user this is unavailable.", feature_disabled: "Agent notifications are disabled for this repository. Ask the user to enable them in integration settings.", + session_scope_denied: + "This session cannot post to this channel because of its visibility or team ownership. Do not retry in another channel without the user's permission.", channel_not_found_or_forbidden: "The channel was not found, is archived, or the bot is not in it. If the channel name is correct and not archived, ask the user to invite the bot.", empty_message_after_sanitization: diff --git a/packages/shared/package.json b/packages/shared/package.json index 4668c0f39f..5481a7a57d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -78,6 +78,10 @@ "import": "./dist/types/teams.js", "types": "./dist/types/teams.d.ts" }, + "./types/team-channel-bindings": { + "import": "./dist/types/team-channel-bindings.js", + "types": "./dist/types/team-channel-bindings.d.ts" + }, "./types/team-access": { "import": "./dist/types/team-access.js", "types": "./dist/types/team-access.d.ts" diff --git a/packages/shared/src/completion/extractor.test.ts b/packages/shared/src/completion/extractor.test.ts index 2de56006a1..8083b4a253 100644 --- a/packages/shared/src/completion/extractor.test.ts +++ b/packages/shared/src/completion/extractor.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; +import { verifyServiceSignature, sha256Hex } from "../service-auth"; import { buildAgentResponseFromEvents, extractAgentResponse, + ProtectedReadError, summarizeToolCall, toArtifactType, toEventArtifactInfo, @@ -296,6 +298,181 @@ describe("buildAgentResponseFromEvents", () => { }); describe("extractAgentResponse", () => { + it.each([undefined, "slack-post"] as const)( + "signs channel and optional purpose on all reads: %s", + async (readPurpose) => { + const requests: { url: URL; headers: Headers }[] = []; + const fetcher: ControlPlaneFetcher = { + async fetch(input, init) { + const url = new URL(String(input)); + requests.push({ url, headers: new Headers(init?.headers) }); + if (url.pathname.endsWith("/events")) { + return Response.json({ + events: [], + hasMore: !url.searchParams.has("cursor"), + cursor: "next", + }); + } + return Response.json({ artifacts: [] }); + }, + }; + await extractAgentResponse( + { fetcher, auth: { service: "slack-bot", secret: "test-secret" }, readPurpose }, + "s1", + "m1", + "trace", + "slack:C123" + ); + expect(requests.map(({ url }) => url.pathname)).toEqual([ + "/sessions/s1/events", + "/sessions/s1/events", + "/sessions/s1/artifacts", + ]); + expect(requests[1]?.url.searchParams.get("cursor")).toBe("next"); + for (const { url, headers } of requests) { + expect( + await verifyServiceSignature({ + signatureHeader: headers.get("X-OpenInspect-Service-Signature")!, + service: "slack-bot", + secret: "test-secret", + method: "GET", + url: url.toString(), + bodySha256Hex: await sha256Hex(""), + actor: "", + }) + ).toMatchObject({ ok: true }); + expect(url.searchParams.get("channel")).toBe("slack:C123"); + expect(url.searchParams.get("purpose")).toBe(readPurpose ?? null); + } + } + ); + + it.each([ + ["events", "403", false], + ["events", "404", false], + ["events", "503", false], + ["events", "malformed", false], + ["events", "network", false], + ["events", "invalid-JSON", false], + ["events", "403", true], + ["events", "404", true], + ["events", "503", true], + ["events", "missing-cursor", false], + ["artifacts", "403", false], + ["artifacts", "404", false], + ["artifacts", "503", false], + ["artifacts", "malformed", false], + ["artifacts", "network", false], + ["artifacts", "invalid-JSON", false], + ] as const)( + "rejects protected %s %s failures (later page: %s) without returning collected content", + async (endpoint, failure, laterPage) => { + const urls: URL[] = []; + const fetcher: ControlPlaneFetcher = { + async fetch(input) { + const url = new URL(String(input)); + urls.push(url); + if ( + url.pathname.endsWith(`/${endpoint}`) && + (!laterPage || url.searchParams.has("cursor")) + ) { + if (failure === "network") throw new Error("read unavailable"); + if (failure === "invalid-JSON") return new Response("{"); + if (failure === "missing-cursor") return Response.json({ events: [], hasMore: true }); + return /^\d+$/.test(failure) + ? Response.json({ error: "read failed" }, { status: Number(failure) }) + : Response.json({ invalid: true }); + } + return Response.json({ + events: [ + { + id: "secret", + type: "token", + data: { content: "SECRET CONTENT" }, + messageId: "m1", + createdAt: 1, + }, + ], + hasMore: laterPage, + cursor: "next", + }); + }, + }; + const extraction = extractAgentResponse( + { + fetcher, + auth: { service: "slack-bot", secret: "test-secret" }, + readPurpose: "slack-post", + }, + "s1", + "m1", + undefined, + "slack:C1" + ); + await expect(extraction).rejects.toBeInstanceOf(ProtectedReadError); + await expect(extraction).rejects.toMatchObject({ + kind: failure === "403" || failure === "404" ? "denied" : "unavailable", + ...(failure === "missing-cursor" ? { message: "Invalid events response" } : {}), + }); + expect(urls).toHaveLength(laterPage || endpoint === "artifacts" ? 2 : 1); + expect(urls.at(-1)?.pathname).toBe(`/sessions/s1/${endpoint}`); + expect(urls.at(-1)?.searchParams.get("cursor")).toBe(laterPage ? "next" : null); + } + ); + + it.each(["403", "network", "malformed", "invalid-JSON"])( + "keeps Linear's unprotected artifact fallback unchanged for %s", + async (failure) => { + const fetcher: ControlPlaneFetcher = { + async fetch(input) { + const url = new URL(String(input)); + if (url.pathname.endsWith("/artifacts")) { + if (failure === "network") throw new Error("read unavailable"); + if (failure === "invalid-JSON") return new Response("{"); + return /^\d+$/.test(failure) + ? Response.json({}, { status: Number(failure) }) + : Response.json({ invalid: true }); + } + return Response.json({ + events: [ + { + id: "text", + type: "token", + data: { content: "Legacy response" }, + messageId: "m1", + createdAt: 1, + }, + { + id: "branch", + type: "artifact", + data: { + artifactType: "branch", + url: "https://example.com/tree/legacy", + metadata: { name: "legacy" }, + }, + messageId: "m1", + createdAt: 2, + }, + ], + hasMore: false, + }); + }, + }; + expect( + await extractAgentResponse( + { fetcher, auth: { service: "linear-bot", secret: "test-secret" } }, + "s1", + "m1" + ) + ).toMatchObject({ + textContent: "Legacy response", + artifacts: [ + { type: "branch", url: "https://example.com/tree/legacy", label: "Branch: legacy" }, + ], + }); + } + ); + it("returns a failed response when the events response is malformed", async () => { const fetcher: ControlPlaneFetcher = { async fetch() { diff --git a/packages/shared/src/completion/extractor.ts b/packages/shared/src/completion/extractor.ts index 80f2ab50ca..b328f9f954 100644 --- a/packages/shared/src/completion/extractor.ts +++ b/packages/shared/src/completion/extractor.ts @@ -25,6 +25,17 @@ import { export type { ControlPlaneFetcher }; +/** Failed purpose-protected reads must never fall back to previously collected content. */ +export class ProtectedReadError extends Error { + readonly kind: "denied" | "unavailable"; + + constructor(message: string, status?: number, options?: ErrorOptions) { + super(message, options); + this.name = "ProtectedReadError"; + this.kind = status === 403 || status === 404 ? "denied" : "unavailable"; + } +} + /** Server-side limit for the events API. */ const EVENTS_PAGE_LIMIT = 200; @@ -50,6 +61,8 @@ export interface ExtractorDeps { * Signatures are request-bound, so headers are built per URL. */ auth: OutboundServiceCredential; + /** Revalidate outbound Slack delivery authority; protected read failures must abort delivery. */ + readPurpose?: "slack-post"; /** Structured logger. Falls back to a silent no-op if not provided. */ log?: Logger; } @@ -80,14 +93,16 @@ const noopLogger: Logger = { * * Events are filtered server-side by `messageId`. Token events contain * cumulative text, so only the last one is kept. Artifacts are fetched from - * the dedicated `/artifacts` endpoint, falling back to inline artifact events - * when the endpoint errors. + * the dedicated `/artifacts` endpoint. Unprotected consumers may fall back to + * inline artifacts; purpose-protected consumers throw on any failed read. + * `channel` is an optional provider-qualified identity (for example, `slack:C123`). */ export async function extractAgentResponse( deps: ExtractorDeps, sessionId: string, messageId: string, - traceId?: string + traceId?: string, + channel?: string ): Promise { const log = deps.log ?? noopLogger; const startTime = Date.now(); @@ -102,6 +117,8 @@ export async function extractAgentResponse( const url = new URL(`https://internal/sessions/${sessionId}/events`); url.searchParams.set("message_id", messageId); url.searchParams.set("limit", String(EVENTS_PAGE_LIMIT)); + if (channel) url.searchParams.set("channel", channel); + if (deps.readPurpose) url.searchParams.set("purpose", deps.readPurpose); if (cursor) { url.searchParams.set("cursor", cursor); } @@ -116,6 +133,11 @@ export async function extractAgentResponse( http_status: response.status, duration_ms: Date.now() - startTime, }); + if (deps.readPurpose) + throw new ProtectedReadError( + `Control plane events read failed: ${response.status}`, + response.status + ); return { textContent: "", toolCalls: [], @@ -133,6 +155,7 @@ export async function extractAgentResponse( error: new Error("Invalid events response"), duration_ms: Date.now() - startTime, }); + if (deps.readPurpose) throw new ProtectedReadError("Invalid events response"); return { textContent: "", toolCalls: [], @@ -146,7 +169,14 @@ export async function extractAgentResponse( cursor = data.hasMore ? data.cursor : undefined; } while (cursor); - const artifacts = await fetchSessionArtifacts(deps, sessionId, traceId, base, allEvents); + const artifacts = await fetchSessionArtifacts( + deps, + sessionId, + traceId, + base, + allEvents, + channel + ); const agentResponse = buildAgentResponseFromEvents(allEvents, artifacts); log.info("control_plane.fetch_events", { @@ -169,6 +199,12 @@ export async function extractAgentResponse( error: error instanceof Error ? error : new Error(String(error)), duration_ms: Date.now() - startTime, }); + if (deps.readPurpose) + throw error instanceof ProtectedReadError + ? error + : new ProtectedReadError("Control plane events read unavailable", undefined, { + cause: error, + }); return { textContent: "", toolCalls: [], artifacts: [], mediaArtifacts: [], success: false }; } } @@ -260,12 +296,16 @@ async function fetchSessionArtifacts( sessionId: string, traceId: string | undefined, base: Record, - events: EventResponse[] + events: EventResponse[], + channel?: string ): Promise { const log = deps.log ?? noopLogger; const eventRange = getEventCreatedAtRange(events); try { - const artifactsUrl = `https://internal/sessions/${sessionId}/artifacts`; + const url = new URL(`https://internal/sessions/${sessionId}/artifacts`); + if (channel) url.searchParams.set("channel", channel); + if (deps.readPurpose) url.searchParams.set("purpose", deps.readPurpose); + const artifactsUrl = url.toString(); const headers = await buildExtractorAuthHeaders(deps, artifactsUrl, traceId); const response = await deps.fetcher.fetch(artifactsUrl, { headers, @@ -277,6 +317,11 @@ async function fetchSessionArtifacts( outcome: "error", http_status: response.status, }); + if (deps.readPurpose) + throw new ProtectedReadError( + `Control plane artifacts read failed: ${response.status}`, + response.status + ); return []; } @@ -287,6 +332,7 @@ async function fetchSessionArtifacts( outcome: "error", error: new Error("Invalid artifacts response"), }); + if (deps.readPurpose) throw new ProtectedReadError("Invalid artifacts response"); return []; } const data = parsed.data; @@ -305,6 +351,12 @@ async function fetchSessionArtifacts( outcome: "error", error: error instanceof Error ? error : new Error(String(error)), }); + if (deps.readPurpose) + throw error instanceof ProtectedReadError + ? error + : new ProtectedReadError("Control plane artifacts read unavailable", undefined, { + cause: error, + }); return []; } } diff --git a/packages/shared/src/slack/client.test.ts b/packages/shared/src/slack/client.test.ts index bf9778cafb..d3c7bc7821 100644 --- a/packages/shared/src/slack/client.test.ts +++ b/packages/shared/src/slack/client.test.ts @@ -310,24 +310,27 @@ describe("getChannelInfo", () => { vi.restoreAllMocks(); }); - it("fetches channel info via GET with bearer auth", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - jsonResponse({ - ok: true, - channel: { id: "C123", name: "ops" }, - }) - ); - - const result = await getChannelInfo("xoxb-token", "C123"); - - expect(result.ok).toBe(true); - expect(result.channel).toEqual({ id: "C123", name: "ops" }); - const [url, init] = fetchSpy.mock.calls[0]!; - expect(url).toBe("https://slack.com/api/conversations.info?channel=C123"); - expect(init?.method ?? "GET").toBe("GET"); - const headers = init?.headers as Record; - expect(headers.Authorization).toBe("Bearer xoxb-token"); - }); + it.each([ + { id: "C123", name: "ops" }, + { id: "C123", name: "ops", is_member: true, is_ext_shared: false }, + ])( + "fetches channel info via GET with bearer auth and retains channel fields: %j", + async (channel) => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ ok: true, channel })); + + const result = await getChannelInfo("xoxb-token", "C123"); + + expect(result.ok).toBe(true); + expect(result.channel).toEqual(channel); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe("https://slack.com/api/conversations.info?channel=C123"); + expect(init?.method ?? "GET").toBe("GET"); + const headers = init?.headers as Record; + expect(headers.Authorization).toBe("Bearer xoxb-token"); + } + ); it("returns Slack's error envelope on lookup failure", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( @@ -338,28 +341,6 @@ describe("getChannelInfo", () => { expect(result.ok).toBe(false); expect(result.error).toBe("channel_not_found"); }); - - it("on 429 returns ratelimited with retryAfter", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response("", { - status: 429, - headers: { "Retry-After": "5" }, - }) - ); - - const result = await getChannelInfo("xoxb-token", "C123"); - expect(result.ok).toBe(false); - expect(result.error).toBe("ratelimited"); - expect(result.retryAfter).toBe(5); - }); - - it("on 5xx returns a typed error", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("oops", { status: 500 })); - - const result = await getChannelInfo("xoxb-token", "C123"); - expect(result.ok).toBe(false); - expect(result.error).toBe("http_500"); - }); }); describe("getPermalink", () => { diff --git a/packages/shared/src/slack/client.ts b/packages/shared/src/slack/client.ts index 8d4fa62e82..adb17a91bd 100644 --- a/packages/shared/src/slack/client.ts +++ b/packages/shared/src/slack/client.ts @@ -403,6 +403,8 @@ export function authTest(token: string): Promise; export const SLACK_DENIAL_STATUS: Record = { feature_unavailable: 503, feature_disabled: 403, + session_scope_denied: 403, empty_message_after_sanitization: 422, channel_not_found_or_forbidden: 404, rate_limited: 429, diff --git a/packages/shared/src/types/audit-events.test.ts b/packages/shared/src/types/audit-events.test.ts index 8f89d3f4e7..4b09987fea 100644 --- a/packages/shared/src/types/audit-events.test.ts +++ b/packages/shared/src/types/audit-events.test.ts @@ -133,6 +133,8 @@ describe("interpretAuditEvent", () => { "team.member_role_changed", "team.member_removed", "team.member_joined", + "team.binding_added", + "team.binding_removed", "team.secret_set", "team.secret_deleted", "automation.executor_changed", diff --git a/packages/shared/src/types/audit-events.ts b/packages/shared/src/types/audit-events.ts index 7cbb8b6d28..b843de89f4 100644 --- a/packages/shared/src/types/audit-events.ts +++ b/packages/shared/src/types/audit-events.ts @@ -90,6 +90,8 @@ export const AUDIT_OPERATION_ACTIONS = [ "team.member_role_changed", "team.member_removed", "team.member_joined", + "team.binding_added", + "team.binding_removed", "team.grant_added", "team.grant_removed", "team.secret_set", diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3431c4ce48..99a6d31422 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -106,6 +106,25 @@ export { export type { Team, TeamRole, TeamJoinPolicy, SessionVisibility, TeamMembership } from "./teams"; export { teamIdSchema } from "./team-id"; +export { + teamChannelBindingProviderSchema, + teamChannelBindingKindSchema, + teamChannelBindingSchema, + putTeamChannelBindingRequestSchema, + teamChannelBindingResponseSchema, + teamChannelBindingsResponseSchema, + channelBindingResponseSchema, +} from "./team-channel-bindings"; +export type { + TeamChannelBindingProvider, + TeamChannelBindingKind, + TeamChannelBinding, + PutTeamChannelBindingRequest, + TeamChannelBindingResponse, + TeamChannelBindingsResponse, + ChannelBindingResponse, +} from "./team-channel-bindings"; + export { SESSION_ACTIONS, AUTOMATION_ACTIONS, diff --git a/packages/shared/src/types/integrations.test.ts b/packages/shared/src/types/integrations.test.ts index 5bd2b8b3bb..eb4b18aaad 100644 --- a/packages/shared/src/types/integrations.test.ts +++ b/packages/shared/src/types/integrations.test.ts @@ -18,12 +18,31 @@ import { supportsConfigurableSandboxTimeout, scmGlobalConfigSchema, scmSettingsSchema, + DEFAULT_SLACK_UNBOUND_CHANNELS, + slackGlobalSettingsSchema, + slackRepoSettingsSchema, integrationSettingsSchemas, slackIntegrationSettingsRoutingResponseSchema, validateSandboxChildSessionLimits, type SlackRoutingRule, } from "./integrations"; +describe("Slack unbound channel policy", () => { + it("defaults to workspace ownership without changing stored optional settings", () => { + expect(DEFAULT_SLACK_UNBOUND_CHANNELS).toBe("workspace"); + expect(slackGlobalSettingsSchema.parse({})).toEqual({}); + }); + + it.each(["workspace", "reject"])("accepts %s only at the global level", (unboundChannels) => { + expect(slackGlobalSettingsSchema.parse({ unboundChannels })).toEqual({ unboundChannels }); + expect(slackRepoSettingsSchema.safeParse({ unboundChannels }).success).toBe(false); + }); + + it.each([null, "team"])("rejects invalid policy %j", (unboundChannels) => { + expect(slackGlobalSettingsSchema.safeParse({ unboundChannels }).success).toBe(false); + }); +}); + describe("sandbox provider settings capabilities", () => { it.each(["modal", "vercel"])("allows resource overrides for %s", (provider) => { expect(supportsConfigurableSandboxResources(provider)).toBe(true); diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index 42c32e9f0f..496928cd49 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -386,6 +386,10 @@ export function resolveBuildTimeoutSeconds(settings: SandboxSettings | undefined export type SlackMentionsPolicy = "allow" | "escape" | "strip"; +export const slackUnboundChannelsSchema = z.enum(["workspace", "reject"]); +export type SlackUnboundChannels = z.infer; +export const DEFAULT_SLACK_UNBOUND_CHANNELS: SlackUnboundChannels = "workspace"; + /** What a Slack routing rule points at: a repository or a saved environment. */ export type SlackRoutingTargetType = "repository" | "environment"; @@ -446,6 +450,8 @@ export type SlackRepoSettings = z.infer; export const slackGlobalSettingsSchema = slackRepoSettingsSchema.extend({ model: z.string().optional(), mentionsPolicy: z.enum(["allow", "escape", "strip"]).optional(), + /** Ownership policy for Slack channels without a Team binding (global-only). */ + unboundChannels: slackUnboundChannelsSchema.optional(), /** Workspace-wide keyword→repository routing rules (global-only, like mentionsPolicy). */ routingRules: z.array(slackRoutingRuleSchema.strict()).optional(), /** Custom instructions appended to the first prompt of every Slack-initiated session. */ diff --git a/packages/shared/src/types/team-channel-bindings.test.ts b/packages/shared/src/types/team-channel-bindings.test.ts new file mode 100644 index 0000000000..21a6759eb8 --- /dev/null +++ b/packages/shared/src/types/team-channel-bindings.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + channelBindingResponseSchema, + putTeamChannelBindingRequestSchema, + teamChannelBindingsResponseSchema, +} from "../index"; + +const binding = { + provider: "slack", + externalId: "C123", + teamId: "team_engineering", + kind: "primary", +} as const; + +describe("team channel binding contracts", () => { + it.each(["primary", "source"])("accepts a %s binding mutation", (kind) => { + expect(putTeamChannelBindingRequestSchema.parse({ kind })).toEqual({ kind }); + }); + + it.each([{}, { kind: "other" }, { kind: "source", teamId: "another-team" }])( + "rejects invalid mutation bodies %j", + (body) => { + expect(putTeamChannelBindingRequestSchema.safeParse(body).success).toBe(false); + } + ); + + it("validates lists and minimal service lookup responses", () => { + const bindings = [binding, { ...binding, provider: "linear" }]; + expect(teamChannelBindingsResponseSchema.parse({ bindings })).toEqual({ bindings }); + expect(channelBindingResponseSchema.parse({ teamId: null })).toEqual({ teamId: null }); + expect(channelBindingResponseSchema.parse({ teamId: binding.teamId, kind: "source" })).toEqual({ + teamId: binding.teamId, + kind: "source", + }); + expect(channelBindingResponseSchema.safeParse({ teamId: binding.teamId }).success).toBe(false); + expect(channelBindingResponseSchema.safeParse({ teamId: null, kind: "source" }).success).toBe( + false + ); + }); +}); diff --git a/packages/shared/src/types/team-channel-bindings.ts b/packages/shared/src/types/team-channel-bindings.ts new file mode 100644 index 0000000000..b63295a2db --- /dev/null +++ b/packages/shared/src/types/team-channel-bindings.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const teamChannelBindingProviderSchema = z.enum(["slack", "linear"]); +export const teamChannelBindingKindSchema = z.enum(["primary", "source"]); + +export const teamChannelBindingSchema = z.strictObject({ + provider: teamChannelBindingProviderSchema, + externalId: z.string().min(1), + teamId: z.string().min(1), + kind: teamChannelBindingKindSchema, +}); + +export const putTeamChannelBindingRequestSchema = z.strictObject({ + kind: teamChannelBindingKindSchema, +}); + +export const teamChannelBindingResponseSchema = z.strictObject({ + binding: teamChannelBindingSchema, +}); + +export const teamChannelBindingsResponseSchema = z.strictObject({ + bindings: z.array(teamChannelBindingSchema), +}); + +/** Bot lookup deliberately exposes no Team metadata beyond its binding scope. */ +export const channelBindingResponseSchema = z.union([ + z.strictObject({ teamId: z.null() }), + z.strictObject({ teamId: z.string().min(1), kind: teamChannelBindingKindSchema }), +]); + +export type TeamChannelBindingProvider = z.infer; +export type TeamChannelBindingKind = z.infer; +export type TeamChannelBinding = z.infer; +export type PutTeamChannelBindingRequest = z.infer; +export type TeamChannelBindingResponse = z.infer; +export type TeamChannelBindingsResponse = z.infer; +export type ChannelBindingResponse = z.infer; diff --git a/packages/slack-bot/src/activity-status.ts b/packages/slack-bot/src/activity-status.ts index 97b181bfac..20627f49bd 100644 --- a/packages/slack-bot/src/activity-status.ts +++ b/packages/slack-bot/src/activity-status.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { createLogger } from "./logger"; import type { Env } from "./types"; +import { isThreadSessionClosed, lookupThreadSession } from "./sessions/thread-session-store"; const SLACK_SET_STATUS_URL = "https://slack.com/api/assistant.threads.setStatus"; const DEFAULT_STATUS_PART_MAX_LENGTH = 80; @@ -249,6 +250,10 @@ export async function setAssistantThreadStatusBestEffort( }; try { + const closed = meta.sessionId + ? await isThreadSessionClosed(env, channel, threadTs, meta.sessionId) + : (await lookupThreadSession(env, channel, threadTs))?.closed; + if (closed) return; const statusText = meta.event === "tool_call" ? ASSISTANT_WORKING_STATUS : status; const requestStatusLength = prepareStatusText(statusText).length; const requestLoadingMessageLengths = [status].map( diff --git a/packages/slack-bot/src/app.ts b/packages/slack-bot/src/app.ts index 1301c7be8d..a678a22cab 100644 --- a/packages/slack-bot/src/app.ts +++ b/packages/slack-bot/src/app.ts @@ -4,6 +4,7 @@ import { threadContextRoutes } from "./routes/thread-context"; import { eventRoutes } from "./routes/events"; import { healthRoutes } from "./routes/health"; import { interactionRoutes } from "./routes/interactions"; +import { channelInfoRoutes } from "./routes/channel-info"; import type { Env } from "./types"; const app = new Hono<{ Bindings: Env }>(); @@ -13,5 +14,6 @@ app.route("/", eventRoutes); app.route("/", interactionRoutes); app.route("/callbacks", callbacksRouter); app.route("/", threadContextRoutes); +app.route("/", channelInfoRoutes); export default app; diff --git a/packages/slack-bot/src/attachments.test.ts b/packages/slack-bot/src/attachments.test.ts index fa1bdfd4f0..52584ad762 100644 --- a/packages/slack-bot/src/attachments.test.ts +++ b/packages/slack-bot/src/attachments.test.ts @@ -58,7 +58,7 @@ function uploadCreatedResponse(attachmentId = "att-1"): Response { /** Download + upload in one step, as the delivery pipeline runs them. */ async function prepareAndUpload(env: Env, sessionId: string, files: SlackMessageFile[]) { const prepared = await prepareImageAttachments(env, toImageAttachments(files)); - return uploadPreparedAttachments(env, sessionId, prepared, "slack:U1"); + return uploadPreparedAttachments(env, sessionId, prepared, "slack:U1", "C123"); } afterEach(() => { @@ -315,7 +315,7 @@ describe("uploadPreparedAttachments", () => { expect(result.sessionMissing).toBe(false); const [uploadUrl, uploadInit] = controlPlaneFetch.mock.calls[0]!; - expect(uploadUrl).toBe("https://internal/sessions/sess-1/attachments"); + expect(uploadUrl).toBe("https://internal/sessions/sess-1/attachments?channel=slack%3AC123"); expect(uploadInit.method).toBe("POST"); // The multipart form is serialized before signing, so the body is the // exact bytes and the Content-Type header carries the boundary they were @@ -353,6 +353,19 @@ describe("uploadPreparedAttachments", () => { actor: "slack:U1", }); expect(verified).toMatchObject({ ok: true }); + const changed = new URL(uploadUrl); + changed.searchParams.set("channel", "slack:C_OTHER"); + expect( + await verifyServiceSignature({ + signatureHeader: headers.get("X-OpenInspect-Service-Signature")!, + service: "slack-bot", + secret: "slack-sig1-secret", + method: "POST", + url: changed.toString(), + bodySha256Hex: await sha256Hex(uploadInit.body as Uint8Array), + actor: "slack:U1", + }) + ).toMatchObject({ ok: false }); }); it("counts rejected uploads as dropped and carries prepare-stage drops forward", async () => { @@ -374,8 +387,8 @@ describe("uploadPreparedAttachments", () => { expect(result.sessionMissing).toBe(false); }); - it("keeps context upload failures out of user drop notices but detects a stale session", async () => { - const controlPlaneFetch = vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 })); + it.each([403, 404])("preserves context upload refusal %s", async (status) => { + const controlPlaneFetch = vi.fn().mockResolvedValueOnce(new Response(null, { status })); const env = makeEnv(controlPlaneFetch); const result = await uploadPreparedAttachments( @@ -391,12 +404,14 @@ describe("uploadPreparedAttachments", () => { ], dropped: [], }, - "slack:U1" + "slack:U1", + "C123" ); expect(result.references).toEqual([]); expect(result.dropped).toEqual([]); - expect(result.sessionMissing).toBe(true); + expect(result.sessionMissing).toBe(status === 404); + expect(result.sessionForbidden).toBe(status === 403 ? true : undefined); }); it("counts malformed upload responses as dropped", async () => { @@ -463,6 +478,32 @@ describe("uploadPreparedAttachments", () => { }); }); +describe("attachment channel scope", () => { + it.each([403, 503])("propagates authoritative upload scope refusal %s", async (status) => { + const env = makeEnv( + vi + .fn(async () => Response.json({ code: "slack_channel_scope_denied" }, { status })) + .mockResolvedValueOnce(uploadCreatedResponse()) + ); + const result = await uploadPreparedAttachments( + env, + "session-1", + { + files: [pngAttachment, { ...pngAttachment, id: "F2" }].map((attachment) => ({ + attachment, + bytes: new Uint8Array(16), + reportDrop: false as const, + })), + dropped: [], + }, + "slack:U1", + "C123" + ); + expect(result.channelScopeDenied).toBe(true); + expect(result.references).toEqual([{ attachmentId: "att-1", name: "screenshot.png" }]); + }); +}); + describe("notifyDroppedAttachments", () => { it("does nothing when nothing was dropped", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); diff --git a/packages/slack-bot/src/attachments.ts b/packages/slack-bot/src/attachments.ts index e7d8a8520c..64790136e8 100644 --- a/packages/slack-bot/src/attachments.ts +++ b/packages/slack-bot/src/attachments.ts @@ -86,6 +86,8 @@ export interface SlackAttachmentUploadResult { * exists, so the failures are stale-session noise rather than real drops. */ sessionMissing: boolean; + sessionForbidden?: true; + channelScopeDenied?: true; } /** @@ -375,9 +377,16 @@ async function uploadToSession( sessionId: string, file: PreparedImageAttachments["files"][number], authorId: string, + channel: string, traceId?: string ): Promise< - { reference: SessionAttachmentReference } | { sessionMissing: boolean; reportDrop: boolean } + | { reference: SessionAttachmentReference } + | { + sessionMissing: boolean; + sessionForbidden?: true; + channelScopeDenied?: true; + reportDrop: boolean; + } > { const { attachment, bytes } = file; try { @@ -393,11 +402,13 @@ async function uploadToSession( if (!contentType) { throw new Error("FormData serialization produced no Content-Type"); } + const url = new URL(`https://internal/sessions/${sessionId}/attachments`); + url.searchParams.set("channel", `slack:${channel}`); const response = await signedControlPlaneFetch( env, { method: "POST", - url: `https://internal/sessions/${sessionId}/attachments`, + url: url.toString(), body: { bytes: multipartBytes, contentType }, actor: authorId.startsWith("slack:") ? authorId : undefined, traceId, @@ -411,7 +422,18 @@ async function uploadToSession( file_id: attachment.id, http_status: response.status, }); - return { sessionMissing: response.status === 404, reportDrop: file.reportDrop !== false }; + const details = await response.json().catch(() => null); + return { + sessionMissing: response.status === 404, + reportDrop: file.reportDrop !== false, + ...(response.status === 403 ? { sessionForbidden: true as const } : {}), + ...(details !== null && + typeof details === "object" && + "code" in details && + details.code === "slack_channel_scope_denied" + ? { channelScopeDenied: true as const } + : {}), + }; } const parsed = sessionAttachmentUploadResponseSchema.safeParse(await response.json()); if (!parsed.success) { @@ -446,14 +468,19 @@ export async function uploadPreparedAttachments( sessionId: string, prepared: PreparedImageAttachments, authorId: string, + channel: string, traceId?: string ): Promise { const outcomes = await Promise.all( - prepared.files.map((file) => uploadToSession(env, sessionId, file, authorId, traceId)) + prepared.files.map((file) => uploadToSession(env, sessionId, file, authorId, channel, traceId)) ); const references: SessionAttachmentReference[] = []; const dropped: SlackAttachmentDropReason[] = [...prepared.dropped]; - const failures: Array<{ sessionMissing: boolean }> = []; + const failures: Array<{ + sessionMissing: boolean; + sessionForbidden?: true; + channelScopeDenied?: true; + }> = []; for (const outcome of outcomes) { if ("reference" in outcome) references.push(outcome.reference); else { @@ -466,14 +493,15 @@ export async function uploadPreparedAttachments( dropped, sessionMissing: references.length === 0 && failures.length > 0 && failures.every((f) => f.sessionMissing), + ...(failures.some((f) => f.sessionForbidden) ? { sessionForbidden: true as const } : {}), + ...(failures.some((f) => f.channelScopeDenied) ? { channelScopeDenied: true as const } : {}), }; } /** * Tell the user how many of their attached images could not be forwarded, with - * guidance matched to why. Call this only once the prompt outcome is known — - * uploads against a stale session fail spuriously and are retried against the - * replacement session. Best effort — never blocks the message. + * guidance matched to why. Call this only once the session proves accessible. + * Best effort: never blocks the message. */ export async function notifyDroppedAttachments( env: Env, diff --git a/packages/slack-bot/src/callbacks.test.ts b/packages/slack-bot/src/callbacks.test.ts index 64a868f938..82569d5d87 100644 --- a/packages/slack-bot/src/callbacks.test.ts +++ b/packages/slack-bot/src/callbacks.test.ts @@ -4,10 +4,18 @@ import { computeHmacHex } from "@open-inspect/shared/auth"; import { callbacksRouter } from "./callbacks"; import { makeExecutionContext as makeCtx } from "./test-helpers"; import type { Env } from "./types"; +import { + isThreadSessionClosed, + lookupThreadSession, + storeThreadSession, +} from "./sessions/thread-session-store"; function makeEnv(overrides: Partial = {}): Env { return { - SLACK_KV: {} as KVNamespace, + SLACK_KV: { + get: vi.fn(async () => null), + put: vi.fn(async () => {}), + } as unknown as KVNamespace, SLACK_COMPLETION_QUEUE: { send: vi.fn(async () => {}) } as unknown as Queue, CONTROL_PLANE: { fetch: vi.fn() } as unknown as Fetcher, DEPLOYMENT_NAME: "test", @@ -273,7 +281,7 @@ describe("POST /callbacks/tool_call", () => { function okFetchMock() { return vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { + new Response(JSON.stringify({ ok: true, channel: "C123", ts: "111.333" }), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -409,19 +417,255 @@ describe("POST /callbacks/activity", () => { expect(response.status).toBe(400); expect(fetchMock).not.toHaveBeenCalled(); }); +}); - it("rejects a payload missing the thread context", async () => { - const fetchMock = okFetchMock(); - const payload = await signPayload({ +describe("POST /callbacks/thread_closed", () => { + afterEach(() => vi.restoreAllMocks()); + const closureKey = "thread-closed:C123:111.222:session-1"; + const noticeKey = `${closureKey}:notice`; + + function closedData(overrides: Record = {}) { + return { + kind: "slack.thread_closed", sessionId: "session-1", - messageId: "msg-1", timestamp: Date.now(), + context: { channel: "C123", threadTs: "111.222" }, + ...overrides, + }; + } + + async function mappedEnv(withMapping = true) { + const values = new Map(); + const env = makeEnv({ + SLACK_KV: { + get: vi.fn(async (key: string, type?: string) => { + const value = values.get(key); + return value === undefined ? null : type === "json" ? JSON.parse(value) : value; + }), + put: vi.fn(async (key: string, value: string) => { + values.set(key, value); + }), + } as unknown as KVNamespace, }); + if (withMapping) + await storeThreadSession(env, "C123", "111.222", { + sessionId: "session-1", + repoId: "acme/app", + repoFullName: "acme/app", + model: "openai/gpt-5.4", + createdAt: 1, + teamId: "team-a", + }); + return env; + } - const { response } = await postCallback("/callbacks/activity", payload); + it("persists closure first but waits for Slack before accepting and marking the notice sent", async () => { + const started = createDeferred(); + const pending = createDeferred(); + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(() => { + started.resolve(); + return pending.promise; + }); + const env = await mappedEnv(); + const payload = await signPayload(closedData()); + let settled = false; + const request = postCallback("/callbacks/thread_closed", payload, env).then((result) => { + settled = true; + return result; + }); + await started.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + try { + expect(settled).toBe(false); + expect(await env.SLACK_KV.get(noticeKey)).toBeNull(); + expect(await isThreadSessionClosed(env, "C123", "111.222", "session-1")).toBe(true); + expect(await lookupThreadSession(env, "C123", "111.222")).toMatchObject({ + closed: true, + teamId: "team-a", + }); + } finally { + pending.resolve(new Response(JSON.stringify({ ok: true, channel: "C123", ts: "111.333" }))); + await request; + } + const { response, ctx } = await request; + expect(response.status).toBe(200); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledOnce(); + expect(slackCall(fetch, "chat.postMessage")?.body).toMatchObject({ + channel: "C123", + thread_ts: "111.222", + text: "this session is no longer available from this channel", + }); + expect(env.SLACK_KV.put).toHaveBeenCalledWith(noticeKey, "1", { + expirationTtl: 7 * 24 * 60 * 60, + }); + expect(await env.SLACK_KV.get(noticeKey)).toBe("1"); + }); - expect(response.status).toBe(400); - expect(fetchMock).not.toHaveBeenCalled(); + it("returns 503 on Slack failure without marking the notice sent, then accepts one successful retry", async () => { + const fetch = okFetchMock().mockResolvedValueOnce( + Response.json({ ok: false, error: "ratelimited" }) + ); + const env = await mappedEnv(); + const payload = await signPayload(closedData()); + const failed = await postCallback("/callbacks/thread_closed", payload, env); + expect(failed.response.status).toBe(503); + expect(await env.SLACK_KV.get(noticeKey)).toBeNull(); + expect(env.SLACK_KV.put).not.toHaveBeenCalledWith(noticeKey, "1", expect.anything()); + expect(await isThreadSessionClosed(env, "C123", "111.222", "session-1")).toBe(true); + fetch.mockClear(); + for (let index = 0; index < 2; index++) { + const { response } = await postCallback("/callbacks/thread_closed", payload, env); + expect(response.status).toBe(200); + } + expect(fetch).toHaveBeenCalledOnce(); + expect(await env.SLACK_KV.get(noticeKey)).toBe("1"); + }); + + it("deduplicates coordinate-only automation closure notices without a mapping", async () => { + const fetch = okFetchMock(); + const env = await mappedEnv(false); + const payload = await signPayload(closedData()); + for (let index = 0; index < 2; index++) { + const { response } = await postCallback("/callbacks/thread_closed", payload, env); + expect(response.status).toBe(200); + } + expect(await env.SLACK_KV.get(closureKey)).toBe("1"); + expect(await lookupThreadSession(env, "C123", "111.222")).toBeNull(); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("returns a retryable failure before posting when closure persistence fails", async () => { + const fetch = okFetchMock(); + const env = await mappedEnv(false); + const put = vi.mocked(env.SLACK_KV.put); + const persist = put.getMockImplementation()!; + put.mockImplementation(async (key, value, options) => { + if (key === closureKey) throw new Error("KV unavailable"); + return persist(key, value, options); + }); + const payload = await signPayload(closedData()); + const failure = await postCallback("/callbacks/thread_closed", payload, env); + expect(failure.response.status).toBe(503); + expect(fetch).not.toHaveBeenCalled(); + put.mockImplementation(persist); + const retry = await postCallback("/callbacks/thread_closed", payload, env); + expect(retry.response.status).toBe(200); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("returns 503 without posting when the sent-marker query fails", async () => { + const fetch = okFetchMock(); + const env = await mappedEnv(); + const get = vi.mocked(env.SLACK_KV.get as (key: string, type?: string) => Promise); + const read = get.getMockImplementation()!; + get.mockImplementation((key, options) => { + if (key === noticeKey) return Promise.reject(new Error("KV unavailable")); + return read(key, options); + }); + const { response } = await postCallback( + "/callbacks/thread_closed", + await signPayload(closedData()), + env + ); + expect(response.status).toBe(503); + expect(fetch).not.toHaveBeenCalled(); + expect(await isThreadSessionClosed(env, "C123", "111.222", "session-1")).toBe(true); + }); + + it("returns a truthful 503 when Slack succeeds but the sent marker cannot be persisted", async () => { + const fetch = okFetchMock(); + const env = await mappedEnv(); + const put = vi.mocked(env.SLACK_KV.put); + const persist = put.getMockImplementation()!; + put.mockImplementation(async (key, value, options) => { + if (key === noticeKey) throw new Error("KV unavailable"); + return persist(key, value, options); + }); + const { response } = await postCallback( + "/callbacks/thread_closed", + await signPayload(closedData()), + env + ); + expect(response.status).toBe(503); + expect(fetch).toHaveBeenCalledOnce(); + expect(put).toHaveBeenCalledWith(noticeKey, "1", expect.anything()); + expect(await env.SLACK_KV.get(noticeKey)).toBeNull(); + expect(await isThreadSessionClosed(env, "C123", "111.222", "session-1")).toBe(true); + }); + + it.each([ + [{ kind: "slack.activity_refresh" }, 400], + [{ timestamp: Date.now() - 10 * 60 * 1000 }, 401], + [{ timestamp: Date.now() + 10 * 60 * 1000 }, 401], + ] as const)("rejects domain or freshness violations %s", async (override, status) => { + const fetch = okFetchMock(); + const { response, ctx } = await postCallback( + "/callbacks/thread_closed", + await signPayload(closedData(override)) + ); + expect(response.status).toBe(status); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects invalid signatures and tombstones an old session without altering a different mapping", async () => { + const fetch = okFetchMock(); + const env = await mappedEnv(); + const invalid = await postCallback( + "/callbacks/thread_closed", + await signPayload(closedData(), "wrong"), + env + ); + expect(invalid.response.status).toBe(401); + const mismatch = await postCallback( + "/callbacks/thread_closed", + await signPayload(closedData({ sessionId: "other" })), + env + ); + expect(mismatch.response.status).toBe(200); + expect(await lookupThreadSession(env, "C123", "111.222")).not.toHaveProperty("closed"); + expect(env.SLACK_KV.put).toHaveBeenCalledWith( + "thread-closed:C123:111.222:other", + "1", + expect.anything() + ); + expect(await isThreadSessionClosed(env, "C123", "111.222", "other")).toBe(true); + expect(await isThreadSessionClosed(env, "C123", "111.222", "session-1")).toBe(false); + expect(mismatch.ctx.waitUntil).not.toHaveBeenCalled(); + expect(await env.SLACK_KV.get("thread-closed:C123:111.222:other:notice")).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("suppresses late callbacks after a coordinate-only closure whose notice failed", async () => { + const fetch = okFetchMock().mockRejectedValueOnce(new Error("network down")); + const env = await mappedEnv(false); + const closed = await postCallback( + "/callbacks/thread_closed", + await signPayload(closedData()), + env + ); + expect(closed.response.status).toBe(503); + expect(await env.SLACK_KV.get(closureKey)).toBe("1"); + expect(await env.SLACK_KV.get(noticeKey)).toBeNull(); + fetch.mockClear(); + const { context } = await makeToolCallPayload(); + const payloads = [ + ["/callbacks/tool_call", { tool: "read", args: {}, callId: "call" }], + ["/callbacks/activity", { kind: "slack.activity_refresh", messageId: "msg" }], + ["/callbacks/complete", { messageId: "msg", success: true }], + ] as const; + for (const [path, fields] of payloads) { + const { response, ctx } = await postCallback( + path, + await signPayload({ sessionId: "session-1", timestamp: Date.now(), context, ...fields }), + env + ); + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + } + expect(fetch).not.toHaveBeenCalled(); + expect(env.SLACK_COMPLETION_QUEUE.send).not.toHaveBeenCalled(); }); }); diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index 8ebb319c65..763ac13c47 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -2,14 +2,22 @@ * Callback handlers for control-plane notifications. */ -import { postEphemeral } from "@open-inspect/shared/slack"; -import { verifyCallbackFromControlPlane } from "@open-inspect/shared/auth"; +import { postEphemeral, postMessage } from "@open-inspect/shared/slack"; +import { TOKEN_VALIDITY_MS, verifyCallbackFromControlPlane } from "@open-inspect/shared/auth"; import { SLACK_ACTIVITY_REFRESH_KIND } from "@open-inspect/shared/types/session-api"; import { Hono, type Context } from "hono"; import { z } from "zod"; import type { Env } from "./types"; import { createSlackCompletionJob, type SlackCompletionJob } from "./completion/job"; import { createLogger } from "./logger"; +import { + closeThreadSession, + isThreadClosureNoticeSent, + isThreadSessionClosed, + lookupThreadSession, + markThreadClosureNoticeSent, + THREAD_CLOSED_MESSAGE, +} from "./sessions/thread-session-store"; import { ASSISTANT_WORKING_STATUS, formatToolStatus, @@ -76,6 +84,14 @@ const activityCallbackSchema = z.looseObject({ context: slackCallbackContextSchema, }); +const threadClosedCallbackSchema = z.looseObject({ + kind: z.literal("slack.thread_closed"), + sessionId: z.string().min(1), + timestamp: z.number().int().nonnegative(), + signature: z.string().min(1), + context: z.object({ channel: z.string().min(1), threadTs: z.string().min(1) }), +}); + type ToolCallCallbackPayload = z.infer; function isSignedCallbackPayload( @@ -179,6 +195,9 @@ async function enqueueCompletion( path: string, startTime: number ): Promise { + if (await isThreadSessionClosed(c.env, job.channel, job.threadTs, job.sessionId)) { + return c.json({ ok: true }); + } try { await c.env.SLACK_COMPLETION_QUEUE.send(job, { contentType: "json" }); } catch (error) { @@ -209,6 +228,65 @@ async function enqueueCompletion( export const callbacksRouter = new Hono<{ Bindings: Env }>(); +callbacksRouter.post("/thread_closed", async (c) => { + const startTime = Date.now(); + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + let payload: unknown; + try { + payload = await c.req.json(); + } catch { + return rejectInvalidPayload(c, "/callbacks/thread_closed", traceId, startTime); + } + const parsed = threadClosedCallbackSchema.safeParse(payload); + if (!parsed.success || !isSignedCallbackPayload(payload)) { + return rejectInvalidPayload(c, "/callbacks/thread_closed", traceId, startTime); + } + const rejection = await rejectInvalidCallback(c, payload, { + path: "/callbacks/thread_closed", + traceId, + startTime, + }); + if (rejection) return rejection; + const valid = parsed.data; + if (Math.abs(startTime - valid.timestamp) > TOKEN_VALIDITY_MS) { + return c.json({ error: "unauthorized" }, 401); + } + const { channel, threadTs } = valid.context; + try { + await closeThreadSession(c.env, channel, threadTs, valid.sessionId); + const mapping = await lookupThreadSession(c.env, channel, threadTs); + if (mapping && mapping.sessionId !== valid.sessionId) return c.json({ ok: true }); + if (await isThreadClosureNoticeSent(c.env, channel, threadTs, valid.sessionId)) { + return c.json({ ok: true }); + } + } catch (error) { + log.error("slack.thread_closed.persist", { + trace_id: traceId, + session_id: valid.sessionId, + error: error instanceof Error ? error : new Error(String(error)), + }); + return c.json({ error: "closure persistence failed" }, 503); + } + try { + const result = await postMessage(c.env.SLACK_BOT_TOKEN, channel, THREAD_CLOSED_MESSAGE, { + thread_ts: threadTs, + }); + if (!result.ok) { + log.warn("slack.thread_closed.post", { trace_id: traceId, slack_error: result.error }); + return c.json({ error: "closure notice delivery failed" }, 503); + } + await markThreadClosureNoticeSent(c.env, channel, threadTs, valid.sessionId); + } catch (error) { + log.error("slack.thread_closed.deliver", { + trace_id: traceId, + session_id: valid.sessionId, + error: error instanceof Error ? error : new Error(String(error)), + }); + return c.json({ error: "closure notice delivery failed" }, 503); + } + return c.json({ ok: true }); +}); + /** * Callback endpoint for session completion notifications. */ diff --git a/packages/slack-bot/src/channel-bindings.ts b/packages/slack-bot/src/channel-bindings.ts new file mode 100644 index 0000000000..ebcc7c494a --- /dev/null +++ b/packages/slack-bot/src/channel-bindings.ts @@ -0,0 +1,48 @@ +import { postMessage } from "@open-inspect/shared/slack"; +import { + channelBindingResponseSchema, + type ChannelBindingResponse, +} from "@open-inspect/shared/types/team-channel-bindings"; +import { controlPlaneFetch, ControlPlaneRequestError } from "./classifier/control-plane"; +import { createLogger } from "./logger"; +import { OUTBOUND_REQUEST_TIMEOUT_MS } from "./request-options"; +import type { Env } from "./types"; + +const log = createLogger("channel-bindings"); + +/** Binding reads are authority: never cache them or fall back to workspace scope. */ +export async function getChannelBinding( + env: Env, + channel: string, + traceId?: string +): Promise { + const path = `/channel-bindings/slack/${encodeURIComponent(channel)}`; + const response = await controlPlaneFetch(env, path, traceId, OUTBOUND_REQUEST_TIMEOUT_MS); + if (!response.ok) throw new ControlPlaneRequestError(path, response.status); + return channelBindingResponseSchema.parse(await response.json()); +} + +export async function resolveChannelBinding( + env: Env, + channel: string, + threadTs: string, + traceId?: string +): Promise { + let message = "I couldn't verify this channel's binding. Please try again."; + try { + return await getChannelBinding(env, channel, traceId); + } catch (error) { + if (error instanceof ControlPlaneRequestError && error.status === 404) { + message = + "This channel is not bound. Ask a team lead or administrator to bind this channel to a team before starting a session."; + } + log.warn("control_plane.channel_binding", { + trace_id: traceId, + channel, + http_status: error instanceof ControlPlaneRequestError ? error.status : undefined, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + await postMessage(env.SLACK_BOT_TOKEN, channel, message, { thread_ts: threadTs }); + return null; +} diff --git a/packages/slack-bot/src/classifier/cached-resource.ts b/packages/slack-bot/src/classifier/cached-resource.ts index 90707bf420..808d5dde45 100644 --- a/packages/slack-bot/src/classifier/cached-resource.ts +++ b/packages/slack-bot/src/classifier/cached-resource.ts @@ -29,7 +29,7 @@ export interface CachedResourceOptions { /** KV key for the last-known-good copy (stores the value as JSON). */ kvKey: string; /** Fetch and parse the fresh value. A throw falls back to the KV copy. */ - load: (env: Env, traceId?: string) => Promise; + load: (env: Env, traceId?: string, teamId?: string | null) => Promise; /** Revive a KV hit; return null to treat it as a miss. */ deserialize: (cached: unknown) => T | null; /** Served when the loader and the KV copy both fail — the fail-open value. */ @@ -37,7 +37,7 @@ export interface CachedResourceOptions { } export interface CachedResource { - get(env: Env, traceId?: string): Promise; + get(env: Env, traceId?: string, teamId?: string | null): Promise; /** * Drop the in-memory copy so the next get() reloads. The KV copy is * deliberately kept — it is fallback data, not authority. @@ -49,11 +49,11 @@ export function createCachedResource(options: CachedResourceOptions): Cach const log = createLogger(options.name); const loadFailureEvent = `control_plane.fetch_${options.name}`; const kvLogKeyPrefix = `${options.name}_cache`; - let memory: { value: T; timestamp: number } | null = null; + const memory = new Map(); - async function readKvFallback(env: Env): Promise { + async function readKvFallback(env: Env, kvKey: string): Promise { try { - const cached = await createKvCacheStore(env.SLACK_KV).get(options.kvKey, "json"); + const cached = await createKvCacheStore(env.SLACK_KV).get(kvKey, "json"); const value = cached === null ? null : options.deserialize(cached); if (value !== null) return value; } catch (e) { @@ -65,18 +65,20 @@ export function createCachedResource(options: CachedResourceOptions): Cach return options.fallback; } - async function get(env: Env, traceId?: string): Promise { - if (memory && Date.now() - memory.timestamp < LOCAL_CACHE_TTL_MS) { - return memory.value; + async function get(env: Env, traceId?: string, teamId?: string | null): Promise { + const kvKey = teamId ? `${options.kvKey}:team:${encodeURIComponent(teamId)}` : options.kvKey; + const cached = memory.get(kvKey); + if (cached && Date.now() - cached.timestamp < LOCAL_CACHE_TTL_MS) { + return cached.value; } const startTime = Date.now(); try { - const value = await options.load(env, traceId); - memory = { value, timestamp: Date.now() }; + const value = await options.load(env, traceId, teamId); + memory.set(kvKey, { value, timestamp: Date.now() }); try { - await createKvCacheStore(env.SLACK_KV).put(options.kvKey, JSON.stringify(value), { + await createKvCacheStore(env.SLACK_KV).put(kvKey, JSON.stringify(value), { expirationTtl: KV_CACHE_TTL_SECONDS, }); } catch (e) { @@ -96,14 +98,14 @@ export function createCachedResource(options: CachedResourceOptions): Cach error: e instanceof Error ? e : new Error(String(e)), duration_ms: Date.now() - startTime, }); - return readKvFallback(env); + return readKvFallback(env, kvKey); } } return { get, invalidate() { - memory = null; + memory.clear(); }, }; } diff --git a/packages/slack-bot/src/classifier/catalog.ts b/packages/slack-bot/src/classifier/catalog.ts index d191fd1966..b12baa1035 100644 --- a/packages/slack-bot/src/classifier/catalog.ts +++ b/packages/slack-bot/src/classifier/catalog.ts @@ -17,14 +17,18 @@ export interface TargetCatalog { } /** - * Fetch both target lists concurrently. Each side is served from its own - * cache; environments fail open to an empty list, so an environments outage - * degrades the whole catalog to repository-only. + * Fetch both target lists concurrently. Channel reads are authorized afresh + * from the live binding and user; unscoped workspace reads use caches. */ -export async function loadTargetCatalog(env: Env, traceId?: string): Promise { +export async function loadTargetCatalog( + env: Env, + traceId?: string, + channelId?: string | null, + userId?: string +): Promise { const [repos, environments] = await Promise.all([ - getAvailableRepos(env, traceId), - getAvailableEnvironments(env, traceId), + getAvailableRepos(env, traceId, channelId, userId), + getAvailableEnvironments(env, traceId, channelId, userId), ]); return { repos, environments }; } diff --git a/packages/slack-bot/src/classifier/control-plane.ts b/packages/slack-bot/src/classifier/control-plane.ts index de9dec7167..786dbb2ece 100644 --- a/packages/slack-bot/src/classifier/control-plane.ts +++ b/packages/slack-bot/src/classifier/control-plane.ts @@ -28,11 +28,17 @@ export async function controlPlaneFetch( env: Env, path: string, traceId?: string, - timeoutMs?: number + timeoutMs?: number, + userId?: string ): Promise { return signedControlPlaneFetch( env, - { method: "GET", url: `https://internal${path}`, traceId }, + { + method: "GET", + url: `https://internal${path}`, + traceId, + actor: userId ? `slack:${userId}` : undefined, + }, { headers: { Accept: "application/json" }, ...(timeoutMs === undefined ? {} : { signal: AbortSignal.timeout(timeoutMs) }), @@ -59,9 +65,10 @@ export class ControlPlaneRequestError extends Error { export async function fetchControlPlaneJson( env: Env, path: string, - traceId?: string + traceId?: string, + userId?: string ): Promise { - const response = await controlPlaneFetch(env, path, traceId); + const response = await controlPlaneFetch(env, path, traceId, undefined, userId); if (!response.ok) { throw new ControlPlaneRequestError(path, response.status); } diff --git a/packages/slack-bot/src/classifier/environments.test.ts b/packages/slack-bot/src/classifier/environments.test.ts index cc4b3360bf..df95b2ea5b 100644 --- a/packages/slack-bot/src/classifier/environments.test.ts +++ b/packages/slack-bot/src/classifier/environments.test.ts @@ -19,7 +19,7 @@ function makeEnv(fetchResult: Response | Error): Env { const fetch = fetchResult instanceof Error ? vi.fn().mockRejectedValue(fetchResult) - : vi.fn().mockResolvedValue(fetchResult); + : vi.fn().mockImplementation(async () => fetchResult.clone()); return { SLACK_KV: { get: vi.fn().mockResolvedValue(null), @@ -46,46 +46,78 @@ describe("getAvailableEnvironments", () => { vi.clearAllMocks(); }); - it("parses environments from the control-plane response", async () => { - const env = makeEnv(jsonResponse({ environments: [TEST_ENVIRONMENT], total: 1 })); - expect(await getAvailableEnvironments(env, "trace")).toEqual([TEST_ENVIRONMENT]); - }); - - it("fails open when the control-plane response is malformed", async () => { - const env = makeEnv(jsonResponse({ environments: [{ id: "env_bad" }], total: 1 })); - expect(await getAvailableEnvironments(env, "trace")).toEqual([]); + it("reads channels afresh per user, keeping workspace reads actorless and cached", async () => { + const env = makeEnv(jsonResponse({ environments: [], total: 0 })); + const fetch = vi.mocked(env.CONTROL_PLANE.fetch); + fetch.mockImplementation(async (_input, init) => { + const name = new Headers(init?.headers).get("X-OpenInspect-Actor")?.slice(6) ?? "workspace"; + return jsonResponse({ + environments: [{ ...TEST_ENVIRONMENT, name }], + total: 1, + }); + }); + expect((await getAvailableEnvironments(env, "trace", null, "U123"))[0].name).toBe("workspace"); + expect((await getAvailableEnvironments(env, "trace", "C1", "U123"))[0].name).toBe("U123"); + expect((await getAvailableEnvironments(env, "trace", "C1", "U456"))[0].name).toBe("U456"); + expect((await getAvailableEnvironments(env, "trace", "C1", "U123"))[0].name).toBe("U123"); + expect((await getAvailableEnvironments(env, "trace", null, "U456"))[0].name).toBe("workspace"); + expect( + fetch.mock.calls.map(([, init]) => new Headers(init?.headers).get("X-OpenInspect-Actor")) + ).toEqual([null, "slack:U123", "slack:U456", "slack:U123"]); + expect(fetch.mock.calls.map(([input]) => String(input))).toEqual([ + "https://internal/environments", + "https://internal/environments?channel=slack%3AC1", + "https://internal/environments?channel=slack%3AC1", + "https://internal/environments?channel=slack%3AC1", + ]); + expect(env.SLACK_KV.put).toHaveBeenCalledTimes(1); + expect(env.SLACK_KV.get).not.toHaveBeenCalled(); }); - it("serves the in-memory cache without refetching", async () => { - const env = makeEnv(jsonResponse({ environments: [TEST_ENVIRONMENT], total: 1 })); - await getAvailableEnvironments(env); - await getAvailableEnvironments(env); - expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); + it("makes no channel catalog request without a current user", async () => { + const env = makeEnv(new Error("should not fetch")); + expect(await getAvailableEnvironments(env, "trace", "C1")).toEqual([]); + expect(await getAvailableEnvironments(env, "trace", "C1", "")).toEqual([]); + expect(env.CONTROL_PLANE.fetch).not.toHaveBeenCalled(); + expect(env.SLACK_KV.get).not.toHaveBeenCalled(); + expect(env.SLACK_KV.put).not.toHaveBeenCalled(); }); - it("fails open to an empty list on a non-OK response", async () => { - const env = makeEnv(new Response("error", { status: 500 })); - expect(await getAvailableEnvironments(env)).toEqual([]); + it.each<[string, Response | Error]>([ + ["denied", new Response(null, { status: 403 })], + ["unavailable", new Response(null, { status: 503 })], + ["offline", new Error("CP offline")], + ["malformed", jsonResponse({ environments: [{ id: "env_bad" }], total: 1 })], + ["invalid JSON", new Response("not JSON")], + ])("fails closed on %s with preseeded caches", async (_name, result) => { + const env = makeEnv(result); + vi.mocked(env.CONTROL_PLANE.fetch).mockResolvedValueOnce( + jsonResponse({ environments: [TEST_ENVIRONMENT], total: 1 }) + ); + const workspaceEnvironments = await getAvailableEnvironments(env, "trace"); + expect(workspaceEnvironments).toEqual([TEST_ENVIRONMENT]); + env.SLACK_KV.get = vi.fn().mockResolvedValue(workspaceEnvironments); + expect(await getAvailableEnvironments(env, "trace", "C1", "U123")).toEqual([]); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); + expect(env.SLACK_KV.get).not.toHaveBeenCalled(); + expect(env.SLACK_KV.put).toHaveBeenCalledTimes(1); + expect(await getAvailableEnvironments(env, "trace")).toBe(workspaceEnvironments); + clearEnvironmentsLocalCache(); + expect(await getAvailableEnvironments(env, "trace")).toEqual(workspaceEnvironments); + expect(env.SLACK_KV.get).toHaveBeenCalledWith("slack:environments", "json"); }); - it("fails open to an empty list when the fetch throws", async () => { - const env = makeEnv(new Error("control plane unreachable")); - expect(await getAvailableEnvironments(env)).toEqual([]); + it("parses environments and retains memory even if the KV write fails", async () => { + const env = makeEnv(jsonResponse({ environments: [TEST_ENVIRONMENT], total: 1 })); + vi.mocked(env.SLACK_KV.put).mockRejectedValueOnce(new Error("KV unavailable")); + expect(await getAvailableEnvironments(env, "trace")).toEqual([TEST_ENVIRONMENT]); + expect(await getAvailableEnvironments(env, "trace")).toEqual([TEST_ENVIRONMENT]); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); }); - it("falls back to the KV cache when the control plane is down", async () => { - const env = { - SLACK_KV: { - get: vi.fn().mockResolvedValue([TEST_ENVIRONMENT]), - put: vi.fn().mockResolvedValue(undefined), - }, - CONTROL_PLANE: { - fetch: vi.fn().mockResolvedValue(new Response("error", { status: 500 })), - }, - SERVICE_AUTH_SECRET: "test-secret", - } as unknown as Env; - - expect(await getAvailableEnvironments(env, "trace")).toEqual([TEST_ENVIRONMENT]); + it("fails open when the control-plane response is malformed", async () => { + const env = makeEnv(jsonResponse({ environments: [{ id: "env_bad" }], total: 1 })); + expect(await getAvailableEnvironments(env, "trace")).toEqual([]); }); it("ignores malformed environments in the KV fallback", async () => { @@ -112,7 +144,15 @@ describe("getEnvironmentById", () => { it("finds an environment by its stable id", async () => { const env = makeEnv(jsonResponse({ environments: [TEST_ENVIRONMENT], total: 1 })); - expect(await getEnvironmentById(env, "env_abc123")).toEqual(TEST_ENVIRONMENT); + expect(await getEnvironmentById(env, "env_abc123", "trace", "C1", "U123")).toEqual( + TEST_ENVIRONMENT + ); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledWith( + "https://internal/environments?channel=slack%3AC1", + expect.objectContaining({ + headers: expect.objectContaining({ "X-OpenInspect-Actor": "slack:U123" }), + }) + ); }); it("returns undefined for an unknown id", async () => { diff --git a/packages/slack-bot/src/classifier/environments.ts b/packages/slack-bot/src/classifier/environments.ts index 516995b0da..c09ead96b1 100644 --- a/packages/slack-bot/src/classifier/environments.ts +++ b/packages/slack-bot/src/classifier/environments.ts @@ -6,13 +6,17 @@ * list**) so an environments-fetch problem never blocks classification — * rules and channel associations targeting an environment are simply skipped, * like rules targeting an inaccessible repository. + * Channel catalogs instead require a current user and bypass all caches. */ import { environmentSchema, listEnvironmentsResponseSchema } from "@open-inspect/shared"; import type { Environment } from "@open-inspect/shared/types/environments"; import type { Env } from "../types"; import { createCachedResource } from "./cached-resource"; -import { fetchControlPlaneJson } from "./control-plane"; +import { ControlPlaneRequestError, fetchControlPlaneJson } from "./control-plane"; +import { createLogger } from "../logger"; + +const log = createLogger("environments"); const environments = createCachedResource({ name: "environments", @@ -38,7 +42,33 @@ const environments = createCachedResource({ /** * Fetch the workspace's environments from the control plane. */ -export async function getAvailableEnvironments(env: Env, traceId?: string): Promise { +export async function getAvailableEnvironments( + env: Env, + traceId?: string, + channelId?: string | null, + userId?: string +): Promise { + if (channelId) { + if (!userId) return []; + // Team membership and grants must be checked on every read. + try { + const body = await fetchControlPlaneJson( + env, + `/environments?channel=${encodeURIComponent(`slack:${channelId}`)}`, + traceId, + userId + ); + return listEnvironmentsResponseSchema.parse(body).environments; + } catch (e) { + log.warn("control_plane.fetch_environments", { + trace_id: traceId, + outcome: "error", + http_status: e instanceof ControlPlaneRequestError ? e.status : undefined, + error: e instanceof Error ? e : new Error(String(e)), + }); + return []; + } + } return environments.get(env, traceId); } @@ -48,9 +78,11 @@ export async function getAvailableEnvironments(env: Env, traceId?: string): Prom export async function getEnvironmentById( env: Env, environmentId: string, - traceId?: string + traceId?: string, + channelId?: string | null, + userId?: string ): Promise { - const all = await getAvailableEnvironments(env, traceId); + const all = await getAvailableEnvironments(env, traceId, channelId, userId); return all.find((environment) => environment.id === environmentId); } diff --git a/packages/slack-bot/src/classifier/index.test.ts b/packages/slack-bot/src/classifier/index.test.ts index 2daf8803b1..65c5b83577 100644 --- a/packages/slack-bot/src/classifier/index.test.ts +++ b/packages/slack-bot/src/classifier/index.test.ts @@ -252,15 +252,26 @@ describe("RepoClassifier", () => { describe("routing rules", () => { it("routes deterministically when a keyword matches, without calling the LLM", async () => { - mockGetRoutingRules.mockResolvedValue([{ keyword: "frontend", target: "acme/web" }]); + mockGetAvailableRepos.mockResolvedValue([TEST_REPOS[1]]); + mockGetRoutingRules.mockResolvedValue([ + { keyword: "frontend", target: "acme/prod" }, + { keyword: "frontend", target: "acme/web" }, + ]); const classifier = new RepoClassifier(TEST_ENV); - const result = await classifier.classify("please fix the frontend nav bug", undefined, "t"); + const result = await classifier.classify( + "please fix the frontend nav bug", + { teamId: "team-a", channelId: "C1", userId: "U123" }, + "t" + ); expect(classifiedRepoFullName(result)).toBe("acme/web"); expect(result.confidence).toBe("high"); expect(result.needsClarification).toBe(false); expect(result.reasoning).toContain("routing rule"); + expect(mockGetAvailableRepos).toHaveBeenCalledWith(TEST_ENV, "t", "C1", "U123"); + expect(mockGetAvailableEnvironments).toHaveBeenCalledWith(TEST_ENV, "t", "C1", "U123"); + expect(mockGetRoutingRules).toHaveBeenCalledWith(TEST_ENV, "t"); expect(mockMessagesCreate).not.toHaveBeenCalled(); }); @@ -391,14 +402,20 @@ describe("RepoClassifier", () => { expect(result.reasoning).not.toContain(""); }); - it("loads the target catalog exactly once per classification", async () => { + it("loads the channel catalog exactly once even without a team binding", async () => { mockGetRoutingRules.mockResolvedValue([{ keyword: "frontend", target: "acme/web" }]); const classifier = new RepoClassifier(TEST_ENV); - await classifier.classify("frontend tweak"); + await classifier.classify( + "frontend tweak", + { teamId: null, channelId: "C123", userId: "U123" }, + "t" + ); expect(mockGetAvailableRepos).toHaveBeenCalledOnce(); expect(mockGetAvailableEnvironments).toHaveBeenCalledOnce(); + expect(mockGetAvailableRepos).toHaveBeenCalledWith(TEST_ENV, "t", "C123", "U123"); + expect(mockGetAvailableEnvironments).toHaveBeenCalledWith(TEST_ENV, "t", "C123", "U123"); }); it("routes an environment rule even when only one repository is available", async () => { diff --git a/packages/slack-bot/src/classifier/index.ts b/packages/slack-bot/src/classifier/index.ts index 56e823ecd7..85e22e7bd3 100644 --- a/packages/slack-bot/src/classifier/index.ts +++ b/packages/slack-bot/src/classifier/index.ts @@ -358,7 +358,7 @@ export class RepoClassifier { // The target catalog every stage below works over. Environments fail open // to []: an environments-fetch problem degrades the catalog — and with it // classification — to repository-only. - const catalog = await loadTargetCatalog(this.env, traceId); + const catalog = await loadTargetCatalog(this.env, traceId, context?.channelId, context?.userId); // Deterministic routing rules (explicit keyword → repo or environment) take // precedence over everything below, but never override an active thread diff --git a/packages/slack-bot/src/classifier/repos.test.ts b/packages/slack-bot/src/classifier/repos.test.ts index 252fecbeb4..1e5560312a 100644 --- a/packages/slack-bot/src/classifier/repos.test.ts +++ b/packages/slack-bot/src/classifier/repos.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sha256Hex, verifyServiceSignature } from "@open-inspect/shared/service-auth"; import type { Env } from "../types"; import { clearLocalCache, @@ -20,7 +21,7 @@ function makeEnv(fetchResult: Response | Error): Env { const fetch = fetchResult instanceof Error ? vi.fn().mockRejectedValue(fetchResult) - : vi.fn().mockResolvedValue(fetchResult); + : vi.fn().mockImplementation(async () => fetchResult.clone()); return { SLACK_KV: { get: vi.fn().mockResolvedValue(null), @@ -37,19 +38,6 @@ describe("getRoutingRules", () => { vi.clearAllMocks(); }); - it("parses routing rules from the control-plane settings response", async () => { - const env = makeEnv( - jsonResponse({ - integrationId: "slack", - settings: { defaults: { routingRules: [{ keyword: "frontend", target: "acme/web" }] } }, - }) - ); - - expect(await getRoutingRules(env, "trace")).toEqual([ - { keyword: "frontend", target: "acme/web" }, - ]); - }); - it("returns an empty list when slack settings are unset", async () => { const env = makeEnv(jsonResponse({ integrationId: "slack", settings: null })); expect(await getRoutingRules(env)).toEqual([]); @@ -86,11 +74,6 @@ describe("getRoutingRules", () => { expect(await getRoutingRules(env)).toEqual([]); }); - it("fails open to an empty list on a non-OK response", async () => { - const env = makeEnv(new Response("error", { status: 500 })); - expect(await getRoutingRules(env)).toEqual([]); - }); - it("fails open to an empty list when the fetch throws", async () => { const env = makeEnv(new Error("control plane unreachable")); expect(await getRoutingRules(env)).toEqual([]); @@ -161,7 +144,113 @@ describe("getAvailableRepos", () => { vi.clearAllMocks(); }); - it("normalizes control-plane repositories and stores them in KV", async () => { + it("reads channels afresh per user, keeping workspace reads actorless and cached", async () => { + const env = makeEnv(jsonResponse({ repos: [], cached: false, cachedAt: "2026-10-01" })); + const fetch = vi.mocked(env.CONTROL_PLANE.fetch); + fetch.mockImplementation(async (_input, init) => { + const name = new Headers(init?.headers).get("X-OpenInspect-Actor")?.slice(6) ?? "workspace"; + return jsonResponse({ + repos: [ + { + id: 1, + owner: "acme", + name, + fullName: `acme/${name}`, + description: null, + archived: false, + private: true, + defaultBranch: "main", + }, + ], + cached: false, + cachedAt: "2026-10-01", + }); + }); + expect((await getAvailableRepos(env, "trace", null, "U123"))[0].name).toBe("workspace"); + expect((await getAvailableRepos(env, "trace", "C1", "U123"))[0].name).toBe("u123"); + expect((await getAvailableRepos(env, "trace", "C1", "U456"))[0].name).toBe("u456"); + expect((await getAvailableRepos(env, "trace", "C1", "U123"))[0].name).toBe("u123"); + expect((await getAvailableRepos(env, "trace", null, "U456"))[0].name).toBe("workspace"); + expect( + fetch.mock.calls.map(([, init]) => new Headers(init?.headers).get("X-OpenInspect-Actor")) + ).toEqual([null, "slack:U123", "slack:U456", "slack:U123"]); + expect(fetch.mock.calls.map(([input]) => String(input))).toEqual([ + "https://internal/repos", + "https://internal/repos?channel=slack%3AC1", + "https://internal/repos?channel=slack%3AC1", + "https://internal/repos?channel=slack%3AC1", + ]); + const [url, init] = fetch.mock.calls[1]; + const headers = new Headers(init?.headers); + const signed = { + signatureHeader: headers.get("X-OpenInspect-Service-Signature") ?? "", + service: "slack-bot" as const, + secret: "test-secret", + method: init?.method ?? "GET", + url: String(url), + bodySha256Hex: await sha256Hex(""), + actor: headers.get("X-OpenInspect-Actor") ?? "", + }; + expect(await verifyServiceSignature(signed)).toMatchObject({ ok: true }); + const changed = new URL(signed.url); + changed.searchParams.set("channel", "slack:C_OTHER"); + expect(await verifyServiceSignature({ ...signed, url: changed.toString() })).toMatchObject({ + ok: false, + reason: "mismatch", + }); + expect(env.SLACK_KV.put).toHaveBeenCalledTimes(1); + expect(env.SLACK_KV.get).not.toHaveBeenCalled(); + }); + + it("makes no channel catalog request without a current user", async () => { + const env = makeEnv(new Error("should not fetch")); + expect(await getAvailableRepos(env, "trace", "C1")).toEqual([]); + expect(await getAvailableRepos(env, "trace", "C1", "")).toEqual([]); + expect(env.CONTROL_PLANE.fetch).not.toHaveBeenCalled(); + expect(env.SLACK_KV.get).not.toHaveBeenCalled(); + expect(env.SLACK_KV.put).not.toHaveBeenCalled(); + }); + + it.each<[string, Response | Error]>([ + ["denied", new Response(null, { status: 403 })], + ["unavailable", new Response(null, { status: 503 })], + ["offline", new Error("CP offline")], + ["malformed", jsonResponse({ repos: [{ owner: "acme", name: "web" }] })], + ["invalid JSON", new Response("not JSON")], + ])("fails closed on %s with preseeded caches", async (_name, result) => { + const env = makeEnv(result); + vi.mocked(env.CONTROL_PLANE.fetch).mockResolvedValueOnce( + jsonResponse({ + repos: [ + { + id: 1, + owner: "acme", + name: "web", + fullName: "acme/web", + description: null, + archived: false, + private: true, + defaultBranch: "main", + }, + ], + cached: false, + cachedAt: "2026-10-01", + }) + ); + const workspaceRepos = await getAvailableRepos(env, "trace"); + expect(workspaceRepos).toHaveLength(1); + env.SLACK_KV.get = vi.fn().mockResolvedValue(workspaceRepos); + expect(await getAvailableRepos(env, "trace", "C1", "U123")).toEqual([]); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); + expect(env.SLACK_KV.get).not.toHaveBeenCalled(); + expect(env.SLACK_KV.put).toHaveBeenCalledTimes(1); + expect(await getAvailableRepos(env, "trace")).toBe(workspaceRepos); + clearLocalCache(); + expect(await getAvailableRepos(env, "trace")).toEqual(workspaceRepos); + expect(env.SLACK_KV.get).toHaveBeenCalledWith("repos:cache", "json"); + }); + + it("normalizes repositories and retains memory even if the KV write fails", async () => { const env = makeEnv( jsonResponse({ repos: [ @@ -187,6 +276,7 @@ describe("getAvailableRepos", () => { }) ); + vi.mocked(env.SLACK_KV.put).mockRejectedValueOnce(new Error("KV unavailable")); const repos = await getAvailableRepos(env, "trace-1"); expect(repos).toEqual([ @@ -207,35 +297,8 @@ describe("getAvailableRepos", () => { expect(env.SLACK_KV.put).toHaveBeenCalledWith("repos:cache", JSON.stringify(repos), { expirationTtl: 300, }); - }); - - it("falls back to cached repos when the control plane returns an error", async () => { - const cachedRepos = [ - { - id: "acme/web", - owner: "acme", - name: "web", - fullName: "acme/web", - displayName: "web", - description: "Cached repo", - defaultBranch: "main", - private: false, - }, - ]; - const env = { - SLACK_KV: { - get: vi.fn().mockResolvedValue(cachedRepos), - put: vi.fn().mockResolvedValue(undefined), - delete: 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-2")).resolves.toEqual(cachedRepos); - expect(env.SLACK_KV.get).toHaveBeenCalledWith("repos:cache", "json"); + expect(await getAvailableRepos(env, "trace-1")).toBe(repos); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); }); it("bounds the catalog fetch and serves the KV fallback when it times out", async () => { @@ -285,19 +348,6 @@ describe("getAvailableRepos", () => { expect(init?.signal).toBe(timeoutSpy.mock.results[0]?.value); }); - 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: { @@ -313,33 +363,6 @@ describe("getAvailableRepos", () => { 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({ - repos: [ - { - id: 1, - owner: "acme", - name: "api", - fullName: "acme/api", - description: null, - private: false, - defaultBranch: "main", - archived: false, - }, - ], - cached: false, - cachedAt: new Date().toISOString(), - }) - ); - - const first = await getAvailableRepos(env); - const second = await getAvailableRepos(env); - - expect(second).toBe(first); - expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); - }); }); describe("getWatchedChannels", () => { diff --git a/packages/slack-bot/src/classifier/repos.ts b/packages/slack-bot/src/classifier/repos.ts index 8085824610..30daafd095 100644 --- a/packages/slack-bot/src/classifier/repos.ts +++ b/packages/slack-bot/src/classifier/repos.ts @@ -55,10 +55,13 @@ export const REPOS_FETCH_TIMEOUT_MS = 5_000; /** * Local in-memory cache for repos. */ -let localCache: { - repos: RepoConfig[]; - timestamp: number; -} | null = null; +const localCache = new Map< + string, + { + repos: RepoConfig[]; + timestamp: number; + } +>(); const WATCHED_CHANNELS_CACHE_KEY = "slack:watched-channels"; @@ -100,19 +103,38 @@ function toRepoConfig(repo: ParsedControlPlaneRepo): RepoConfig { * 1. Checks local in-memory cache first * 2. Calls the control plane GET /repos endpoint * 3. Falls back to FALLBACK_REPOS if the API fails + * Channel catalogs require a current user and bypass all caches and fallbacks. * * @param env - Cloudflare Worker environment * @returns Array of RepoConfig objects */ -export async function getAvailableRepos(env: Env, traceId?: string): Promise { +export async function getAvailableRepos( + env: Env, + traceId?: string, + channelId?: string | null, + userId?: string +): Promise { + if (channelId && !userId) return []; + const cacheKey = "repos:cache"; + // Team membership and grants must be checked on every read. + const cached = channelId ? undefined : localCache.get(cacheKey); // Check local cache first - if (localCache && Date.now() - localCache.timestamp < LOCAL_CACHE_TTL_MS) { - return localCache.repos; + if (cached && Date.now() - cached.timestamp < LOCAL_CACHE_TTL_MS) { + return cached.repos; } const startTime = Date.now(); try { - const response = await controlPlaneFetch(env, "/repos", traceId, REPOS_FETCH_TIMEOUT_MS); + const path = channelId + ? `/repos?channel=${encodeURIComponent(`slack:${channelId}`)}` + : "/repos"; + const response = await controlPlaneFetch( + env, + path, + traceId, + REPOS_FETCH_TIMEOUT_MS, + channelId ? userId : undefined + ); if (!response.ok) { log.error("control_plane.fetch_repos", { @@ -121,7 +143,7 @@ export async function getAvailableRepos(env: Env, traceId?: string): Promise { +async function getFromCacheOrFallback(env: Env, cacheKey: string): Promise { try { - const cached = await createKvCacheStore(env.SLACK_KV).get("repos:cache", "json"); + const cached = await createKvCacheStore(env.SLACK_KV).get(cacheKey, "json"); const parsed = z.array(repoConfigSchema).safeParse(cached); if (parsed.success) { log.info("control_plane.fetch_repos", { source: "kv_cache" }); @@ -347,6 +371,6 @@ export function buildRepoDescriptions(repos: RepoConfig[]): string { * classifier/environments.ts. */ export function clearLocalCache(): void { - localCache = null; + localCache.clear(); routingRules.invalidate(); } diff --git a/packages/slack-bot/src/completion/consumer.test.ts b/packages/slack-bot/src/completion/consumer.test.ts index d2c7f8ab75..1ac6348b42 100644 --- a/packages/slack-bot/src/completion/consumer.test.ts +++ b/packages/slack-bot/src/completion/consumer.test.ts @@ -11,19 +11,7 @@ vi.mock("./delivery", async (importOriginal) => { }); function makeEnv(): Env { - return { - SLACK_KV: {} as KVNamespace, - SLACK_COMPLETION_QUEUE: {} as Queue, - CONTROL_PLANE: {} as Fetcher, - DEPLOYMENT_NAME: "test", - CONTROL_PLANE_URL: "https://control-plane.test", - WEB_APP_URL: "https://app.test", - DEFAULT_MODEL: "anthropic/claude-haiku-4-5", - CLASSIFICATION_MODEL: "anthropic/claude-haiku-4-5", - SLACK_BOT_TOKEN: "xoxb-test", - SLACK_SIGNING_SECRET: "signing-secret", - ANTHROPIC_API_KEY: "test-key", - }; + return {} as Env; } function job(): SlackCompletionJob { @@ -65,6 +53,7 @@ describe("consumeSlackCompletions", () => { }); it("processes and acknowledges a valid completion", async () => { + vi.mocked(processSlackCompletion).mockResolvedValue({ kind: "ack" }); const input = batch(job()); await consumeSlackCompletions(input as unknown as MessageBatch, makeEnv()); @@ -74,6 +63,17 @@ describe("consumeSlackCompletions", () => { expect(input.message.retry).not.toHaveBeenCalled(); }); + it("retries only an explicit safe pre-publication unavailable result", async () => { + vi.mocked(processSlackCompletion).mockResolvedValue({ kind: "retry" }); + const input = batch(job()); + + await consumeSlackCompletions(input as unknown as MessageBatch, makeEnv()); + + expect(input.message.retry).toHaveBeenCalledOnce(); + expect(input.message.retry).toHaveBeenCalledWith(); + expect(input.message.ack).not.toHaveBeenCalled(); + }); + it("acknowledges invalid jobs without processing them", async () => { const input = batch({ version: 99 }); @@ -83,7 +83,7 @@ describe("consumeSlackCompletions", () => { expect(input.message.ack).toHaveBeenCalledOnce(); }); - it("acknowledges processing errors instead of risking duplicate Slack side effects", async () => { + it("acknowledges unhandled errors instead of assuming replay safety", async () => { vi.mocked(processSlackCompletion).mockRejectedValue(new Error("unexpected")); const input = batch(job()); diff --git a/packages/slack-bot/src/completion/consumer.ts b/packages/slack-bot/src/completion/consumer.ts index 4bfef177ff..ce3ea96603 100644 --- a/packages/slack-bot/src/completion/consumer.ts +++ b/packages/slack-bot/src/completion/consumer.ts @@ -22,7 +22,11 @@ export async function consumeSlackCompletions( } try { - await processSlackCompletion(parsed.data, env); + const result = await processSlackCompletion(parsed.data, env); + if (result?.kind === "retry") { + message.retry(); + continue; + } } catch (error) { log.error("slack.completion.unhandled", { delivery_id: parsed.data.deliveryId, @@ -31,7 +35,7 @@ export async function consumeSlackCompletions( error: error instanceof Error ? error : new Error(String(error)), }); } - // Processing may already have produced Slack side effects. Retrying here can duplicate them. + // An unhandled failure has unknown publication state. Only an explicit safe result permits replay. message.ack(); } } diff --git a/packages/slack-bot/src/completion/delivery.test.ts b/packages/slack-bot/src/completion/delivery.test.ts index 26cfedb501..32d185ac8f 100644 --- a/packages/slack-bot/src/completion/delivery.test.ts +++ b/packages/slack-bot/src/completion/delivery.test.ts @@ -4,6 +4,8 @@ import { extractAgentResponse } from "./extractor"; import { deliverMediaArtifacts } from "./media-upload"; import type { SlackCompletionJob } from "./job"; import type { AgentResponse } from "@open-inspect/shared/types/artifacts"; +import * as ThreadSessionStore from "../sessions/thread-session-store"; +import * as CompletionBlocks from "./blocks"; import type { Env } from "../types"; import type * as ExtractorModule from "./extractor"; import type * as MediaUploadModule from "./media-upload"; @@ -20,7 +22,7 @@ vi.mock("./media-upload", async (importOriginal) => { function makeEnv(overrides: Partial = {}): Env { return { - SLACK_KV: {} as KVNamespace, + SLACK_KV: { get: vi.fn(async () => null) } as unknown as KVNamespace, SLACK_COMPLETION_QUEUE: {} as Queue, CONTROL_PLANE: { fetch: vi.fn() } as unknown as Fetcher, DEPLOYMENT_NAME: "test", @@ -92,7 +94,15 @@ describe("processSlackCompletion", () => { .mockResolvedValueOnce(Response.json({ ok: true })); const env = makeEnv(); - await processSlackCompletion(job(), env); + await expect(processSlackCompletion(job(), env)).resolves.toEqual({ kind: "ack" }); + + expect(extractAgentResponse).toHaveBeenCalledWith( + env, + "session-1", + "message-1", + "C123", + "trace-1" + ); expect(deliverMediaArtifacts).toHaveBeenCalledWith({ env, @@ -102,6 +112,7 @@ describe("processSlackCompletion", () => { threadTs: "111.222", artifacts: [{ id: "image-1", type: "screenshot" }], traceId: "trace-1", + onShareAttempt: expect.any(Function), }); expect(fetchMock).toHaveBeenCalledTimes(3); expect(String(fetchMock.mock.calls[0]?.[0])).toContain("chat.postMessage"); @@ -109,20 +120,21 @@ describe("processSlackCompletion", () => { expect(String(fetchMock.mock.calls[2]?.[0])).toContain("reactions.remove"); }); - it("skips media delivery when the response has no media artifacts", async () => { - vi.mocked(extractAgentResponse).mockResolvedValue({ - ...successfulAgentResponse(), - mediaArtifacts: [], + it("suppresses an automation completion with only a coordinate/session tombstone", async () => { + const fetch = vi.spyOn(globalThis, "fetch"); + const env = makeEnv({ + SLACK_KV: { + get: vi.fn(async (key: string) => + key === "thread-closed:C123:111.222:session-1" ? "1" : null + ), + } as unknown as KVNamespace, }); - const fetchMock = vi - .spyOn(globalThis, "fetch") - .mockResolvedValueOnce(Response.json({ ok: true, channel: "C123", ts: "333.444" })) - .mockResolvedValueOnce(Response.json({ ok: true })); - - await processSlackCompletion(job(), makeEnv()); - + await expect(processSlackCompletion(job({ source: "automation" }), env)).resolves.toEqual({ + kind: "ack", + }); + expect(extractAgentResponse).not.toHaveBeenCalled(); expect(deliverMediaArtifacts).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetch).not.toHaveBeenCalled(); }); it("lets Slack derive accessible fallback text from completion blocks", async () => { @@ -137,14 +149,17 @@ describe("processSlackCompletion", () => { await processSlackCompletion(job(), makeEnv()); + expect(deliverMediaArtifacts).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(2); const request = fetchMock.mock.calls[0]?.[1]; const body = JSON.parse(String(request?.body)) as Record; expect(body).not.toHaveProperty("text"); expect(body.blocks).toBeDefined(); }); - it("skips media when the ordinary completion post fails", async () => { + it("validates media before the ordinary completion post and stops after a failed post", async () => { vi.mocked(extractAgentResponse).mockResolvedValue(successfulAgentResponse()); + vi.mocked(deliverMediaArtifacts).mockResolvedValue({ uploaded: 1, failed: 0, omitted: 0 }); const fetchMock = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(Response.json({ ok: false, error: "channel_not_found" })) @@ -152,25 +167,182 @@ describe("processSlackCompletion", () => { await processSlackCompletion(job(), makeEnv()); - expect(deliverMediaArtifacts).not.toHaveBeenCalled(); + expect(deliverMediaArtifacts).toHaveBeenCalledOnce(); expect(String(fetchMock.mock.calls[1]?.[0])).toContain("reactions.remove"); }); - it("clears the reaction when extraction throws", async () => { - vi.mocked(extractAgentResponse).mockRejectedValue(new Error("control plane unavailable")); - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); + it("retries a closure lookup failure before publishing anything", async () => { + vi.spyOn(ThreadSessionStore, "isThreadSessionClosed").mockRejectedValueOnce( + new Error("KV unavailable") + ); + const fetch = vi.spyOn(globalThis, "fetch"); + await expect(processSlackCompletion(job(), makeEnv())).resolves.toEqual({ kind: "retry" }); + expect(extractAgentResponse).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); - await expect(processSlackCompletion(job(), makeEnv())).resolves.toBeUndefined(); + it("keeps the reaction untouched when preparation fails after successful reads", async () => { + vi.mocked(extractAgentResponse).mockResolvedValue({ + ...successfulAgentResponse(), + mediaArtifacts: [], + }); + vi.spyOn(CompletionBlocks, "buildCompletionBlocks").mockImplementation(() => { + throw new Error("block preparation failed"); + }); + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); + await expect(processSlackCompletion(job(), makeEnv())).resolves.toEqual({ kind: "retry" }); + expect(fetch).not.toHaveBeenCalled(); + }); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(String(fetchMock.mock.calls[0]?.[0])).toContain("reactions.remove"); + it.each([ + ["events", 403, true], + ["artifacts", 404, false], + ["artifacts", 503, false], + ] as const)( + "suppresses job content after protected %s status %s for success=%s", + async (endpoint, status, success) => { + const actual = await vi.importActual("./extractor"); + vi.mocked(extractAgentResponse).mockImplementation(actual.extractAgentResponse); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ ok: true, channel: "C123", ts: "333.444" })); + const env = makeEnv(); + const cpFetch = vi.mocked(env.CONTROL_PLANE.fetch); + cpFetch.mockImplementation(async (input) => { + const url = new URL(String(input)); + if (url.pathname.endsWith(`/${endpoint}`)) return Response.json({}, { status }); + return Response.json({ + events: [ + { + id: "secret", + type: "token", + data: { content: "SECRET CONTENT" }, + messageId: "message-1", + createdAt: 1, + }, + ], + hasMore: false, + }); + }); + await expect( + processSlackCompletion(job({ success, error: "SECRET JOB ERROR" }), env) + ).resolves.toEqual({ kind: status === 503 ? "retry" : "ack" }); + expect(cpFetch).toHaveBeenCalledTimes(endpoint === "events" ? 1 : 2); + for (const [url] of cpFetch.mock.calls) { + expect(new URL(String(url)).searchParams.get("channel")).toBe("slack:C123"); + expect(new URL(String(url)).searchParams.get("purpose")).toBe("slack-post"); + } + expect(fetch).not.toHaveBeenCalled(); + expect(deliverMediaArtifacts).not.toHaveBeenCalled(); + } + ); + + it.each(["allowed", "denied", "unavailable"] as const)( + "gates cached completion text after missing media on a fresh %s publication proof", + async (access) => { + vi.mocked(extractAgentResponse).mockResolvedValue(successfulAgentResponse()); + const actual = await vi.importActual("./media-upload"); + vi.mocked(deliverMediaArtifacts).mockImplementation(actual.deliverMediaArtifacts); + let proofChecked = false; + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + expect(proofChecked).toBe(true); + return Response.json({ ok: true, channel: "C123", ts: "333.444" }); + }); + const env = makeEnv(); + vi.mocked(env.CONTROL_PLANE.fetch).mockImplementation(async (input) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/artifacts")) { + proofChecked = true; + if (access === "allowed") return Response.json({ artifacts: [] }); + return new Response(null, { status: access === "denied" ? 404 : 503 }); + } + return new Response(null, { status: 404 }); + }); + + await expect( + processSlackCompletion(job({ error: "SECRET JOB ERROR" }), env) + ).resolves.toEqual({ kind: access === "unavailable" ? "retry" : "ack" }); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); + const [proofUrl] = vi.mocked(env.CONTROL_PLANE.fetch).mock.calls[1]!; + expect(new URL(String(proofUrl)).pathname).toBe("/sessions/session-1/artifacts"); + expect(new URL(String(proofUrl)).searchParams.get("channel")).toBe("slack:C123"); + expect(new URL(String(proofUrl)).searchParams.get("purpose")).toBe("slack-post"); + const posts = fetch.mock.calls.filter(([url]) => String(url).includes("chat.postMessage")); + if (access === "allowed") { + expect(posts).toHaveLength(2); + expect(String(posts[0]?.[1]?.body)).toContain("Generated the chart."); + expect(String(posts[1]?.[1]?.body)).toContain("could not be attached here"); + expect(fetch.mock.calls.some(([url]) => String(url).includes("reactions.remove"))).toBe( + true + ); + } else { + expect(posts).toHaveLength(0); + expect(fetch).not.toHaveBeenCalled(); + } + } + ); + + it("does not replay accepted media if the following closure check throws", async () => { + vi.mocked(extractAgentResponse).mockResolvedValue(successfulAgentResponse()); + const actual = await vi.importActual("./media-upload"); + vi.mocked(deliverMediaArtifacts).mockImplementation(actual.deliverMediaArtifacts); + const env = makeEnv(); + vi.mocked(env.CONTROL_PLANE.fetch).mockResolvedValueOnce( + new Response("png-bytes", { + headers: { "Content-Type": "image/png", "Content-Length": "9" }, + }) + ); + let shared = false; + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + Response.json({ ok: true, upload_url: "https://files.slack.com/upload/one", file_id: "F1" }) + ) + .mockResolvedValueOnce(new Response("OK")) + .mockImplementationOnce(async () => { + shared = true; + return Response.json({ ok: true, files: [{ id: "F1" }] }); + }); + vi.spyOn(ThreadSessionStore, "isThreadSessionClosed").mockImplementation(async () => { + if (shared) throw new Error("KV unavailable after share"); + return false; + }); + + await expect(processSlackCompletion(job(), env)).resolves.toEqual({ kind: "ack" }); + expect(shared).toBe(true); + expect(fetch).toHaveBeenCalledTimes(3); + expect(String(fetch.mock.calls[2]?.[0])).toContain("files.completeUploadExternal"); }); + it.each([true, false])( + "does not replay an ambiguous text post for job success=%s", + async (success) => { + vi.mocked(extractAgentResponse).mockResolvedValue({ + ...successfulAgentResponse(), + textContent: success ? "Finished." : "", + mediaArtifacts: [], + }); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockRejectedValueOnce(new Error("Slack accepted the post but the response was lost")) + .mockResolvedValueOnce(Response.json({ ok: true })); + + await expect(processSlackCompletion(job({ success }), makeEnv())).resolves.toEqual({ + kind: "ack", + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[0]?.[0])).toContain("chat.postMessage"); + expect(String(fetch.mock.calls[1]?.[0])).toContain("reactions.remove"); + } + ); + it("posts nothing but still clears the reaction when an automation declines", async () => { vi.mocked(extractAgentResponse).mockResolvedValue(declinedAgentResponse()); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); - await processSlackCompletion(job({ source: "automation" }), makeEnv()); + await expect(processSlackCompletion(job({ source: "automation" }), makeEnv())).resolves.toEqual( + { kind: "ack" } + ); expect(deliverMediaArtifacts).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledOnce(); diff --git a/packages/slack-bot/src/completion/delivery.ts b/packages/slack-bot/src/completion/delivery.ts index 650802d740..4d6011a475 100644 --- a/packages/slack-bot/src/completion/delivery.ts +++ b/packages/slack-bot/src/completion/delivery.ts @@ -1,11 +1,13 @@ import { postBlocks, postMessage, removeReaction } from "@open-inspect/shared/slack"; import type { AgentResponse } from "@open-inspect/shared/types/artifacts"; +import { ProtectedReadError } from "@open-inspect/shared/completion/extractor"; import type { Env } from "../types"; import { createLogger } from "../logger"; import { extractAgentResponse } from "./extractor"; import { buildCompletionBlocks, truncateError } from "./blocks"; import { deliverMediaArtifacts } from "./media-upload"; import type { SlackCompletionJob } from "./job"; +import { isThreadSessionClosed } from "../sessions/thread-session-store"; const log = createLogger("completion-delivery"); @@ -48,7 +50,15 @@ export function shouldDeclineReply( return text === "" || NO_REPLY_PATTERN.test(text); } -export async function processSlackCompletion(job: SlackCompletionJob, env: Env): Promise { +export type SlackCompletionDeliveryResult = + | { kind: "ack" } + /** Preparation failures before any publication attempt permit replay. */ + | { kind: "retry" }; + +export async function processSlackCompletion( + job: SlackCompletionJob, + env: Env +): Promise { const startTime = Date.now(); const base = { trace_id: job.traceId, @@ -58,14 +68,42 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): message_id: job.messageId, channel: job.channel, }; + let shouldClearReaction = false; + let publicationAttempted = false; try { + if (await isThreadSessionClosed(env, job.channel, job.threadTs, job.sessionId)) + return { kind: "ack" }; const agentResponse = await extractAgentResponse( env, job.sessionId, job.messageId, + job.channel, job.traceId ); + if (await isThreadSessionClosed(env, job.channel, job.threadTs, job.sessionId)) + return { kind: "ack" }; + const mediaArtifacts = agentResponse.mediaArtifacts ?? []; + let unavailableMedia = 0; + // Complete protected media reads before emitting any completion message or fallback metadata. + if (mediaArtifacts.length > 0) { + const mediaResult = await deliverMediaArtifacts({ + env, + sessionId: job.sessionId, + messageId: job.messageId, + channel: job.channel, + threadTs: job.threadTs, + artifacts: mediaArtifacts, + traceId: job.traceId, + onShareAttempt: () => { + publicationAttempted = true; + }, + }); + unavailableMedia = mediaResult.failed + mediaResult.omitted; + if (await isThreadSessionClosed(env, job.channel, job.threadTs, job.sessionId)) + return { kind: "ack" }; + } + shouldClearReaction = true; agentResponse.error = agentResponse.error || job.error; if (!agentResponse.textContent && agentResponse.toolCalls.length === 0 && !job.success) { @@ -77,6 +115,7 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): agent_error: agentResponse.error || "Unknown error", duration_ms: Date.now() - startTime, }); + publicationAttempted = true; await postMessage(env.SLACK_BOT_TOKEN, job.channel, `The agent failed: ${displayError}`, { thread_ts: job.threadTs, blocks: [ @@ -97,7 +136,7 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): }, ], }); - return; + return { kind: "ack" }; } if (shouldDeclineReply(job, agentResponse)) { @@ -109,7 +148,7 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): tool_call_count: agentResponse.toolCalls.length, duration_ms: Date.now() - startTime, }); - return; + return { kind: "ack" }; } const blocks = buildCompletionBlocks( @@ -125,6 +164,7 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): env.WEB_APP_URL ); // Without top-level text, Slack derives screen-reader text from the blocks. + publicationAttempted = true; const postResult = await postBlocks(env.SLACK_BOT_TOKEN, job.channel, blocks, { thread_ts: job.threadTs, }); @@ -136,29 +176,19 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): retry_after: postResult.retryAfter, }); // A network error can be ambiguous; replaying the job may duplicate a Slack completion. - return; + return { kind: "ack" }; } - const mediaArtifacts = agentResponse.mediaArtifacts ?? []; - if (mediaArtifacts.length > 0) { - const mediaResult = await deliverMediaArtifacts({ - env, - sessionId: job.sessionId, - messageId: job.messageId, - channel: job.channel, - threadTs: job.threadTs, - artifacts: mediaArtifacts, - traceId: job.traceId, - }); - const unavailable = mediaResult.failed + mediaResult.omitted; - if (unavailable > 0) { - await postMessage( - env.SLACK_BOT_TOKEN, - job.channel, - `${unavailable} media artifact${unavailable === 1 ? " is" : "s are"} available in the session but could not be attached here.`, - { thread_ts: job.threadTs } - ); - } + if ( + unavailableMedia > 0 && + !(await isThreadSessionClosed(env, job.channel, job.threadTs, job.sessionId)) + ) { + await postMessage( + env.SLACK_BOT_TOKEN, + job.channel, + `${unavailableMedia} media artifact${unavailableMedia === 1 ? " is" : "s are"} available in the session but could not be attached here.`, + { thread_ts: job.threadTs } + ); } log.info("callback.complete", { @@ -171,6 +201,7 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): has_text: Boolean(agentResponse.textContent), duration_ms: Date.now() - startTime, }); + return { kind: "ack" }; } catch (error) { log.error("callback.complete", { ...base, @@ -178,8 +209,15 @@ export async function processSlackCompletion(job: SlackCompletionJob, env: Env): error: error instanceof Error ? error : new Error(String(error)), duration_ms: Date.now() - startTime, }); + if (!publicationAttempted) shouldClearReaction = false; + return { + kind: + publicationAttempted || (error instanceof ProtectedReadError && error.kind === "denied") + ? "ack" + : "retry", + }; } finally { - if (job.reactionMessageTs) { + if (shouldClearReaction && job.reactionMessageTs) { await clearThinkingReaction(env, job.channel, job.reactionMessageTs, job.traceId); } } diff --git a/packages/slack-bot/src/completion/extractor.test.ts b/packages/slack-bot/src/completion/extractor.test.ts index 53abaa5391..4986799f8f 100644 --- a/packages/slack-bot/src/completion/extractor.test.ts +++ b/packages/slack-bot/src/completion/extractor.test.ts @@ -64,10 +64,14 @@ describe("extractAgentResponse", () => { SERVICE_AUTH_SECRET: "test-secret", } as unknown as Env; - const response = await extractAgentResponse(env, "session-1", "msg-1"); + const response = await extractAgentResponse(env, "session-1", "msg-1", "C123"); expect(response.textContent).toBe("Final response"); expect(response.success).toBe(true); + for (const [input] of fetchMock.mock.calls) { + expect(new URL(String(input)).searchParams.get("channel")).toBe("slack:C123"); + expect(new URL(String(input)).searchParams.get("purpose")).toBe("slack-post"); + } expect(response.artifacts).toEqual([ { type: "pr", @@ -83,56 +87,4 @@ describe("extractAgentResponse", () => { }, ]); }); - - it("falls back to event artifacts when artifacts API errors", async () => { - const fetchMock = vi.fn(async (input: string | URL | Request) => { - const url = String(input); - if (url.includes("/events")) { - return jsonResponse({ - events: [ - { - id: "evt-artifact", - type: "artifact", - data: { - artifactType: "branch", - url: "https://github.com/octocat/repo/tree/feature", - metadata: { name: "feature" }, - }, - messageId: "msg-2", - createdAt: 20, - }, - { - id: "evt-complete", - type: "execution_complete", - data: { success: true }, - messageId: "msg-2", - createdAt: 21, - }, - ], - hasMore: false, - }); - } - - if (url.includes("/artifacts")) { - return jsonResponse({ error: "failed" }, 500); - } - - return new Response("Not found", { status: 404 }); - }); - - const env = { - CONTROL_PLANE: { fetch: fetchMock }, - SERVICE_AUTH_SECRET: "test-secret", - } as unknown as Env; - - const response = await extractAgentResponse(env, "session-2", "msg-2"); - - expect(response.artifacts).toEqual([ - { - type: "branch", - url: "https://github.com/octocat/repo/tree/feature", - label: "Branch: feature", - }, - ]); - }); }); diff --git a/packages/slack-bot/src/completion/extractor.ts b/packages/slack-bot/src/completion/extractor.ts index cbfbcd67f1..436d441093 100644 --- a/packages/slack-bot/src/completion/extractor.ts +++ b/packages/slack-bot/src/completion/extractor.ts @@ -22,16 +22,19 @@ export async function extractAgentResponse( env: Env, sessionId: string, messageId: string, + channel: string, traceId?: string ): Promise { return sharedExtract( { fetcher: env.CONTROL_PLANE, auth: resolveOutboundCredential("slack-bot", env), + readPurpose: "slack-post", log, }, sessionId, messageId, - traceId + traceId, + `slack:${channel}` ); } diff --git a/packages/slack-bot/src/completion/media-upload.test.ts b/packages/slack-bot/src/completion/media-upload.test.ts index 8f22319539..a1751bbb55 100644 --- a/packages/slack-bot/src/completion/media-upload.test.ts +++ b/packages/slack-bot/src/completion/media-upload.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { MediaArtifactInfo } from "@open-inspect/shared/types/artifacts"; +import { ProtectedReadError } from "@open-inspect/shared/completion/extractor"; import { deliverMediaArtifacts, SLACK_MEDIA_MAX_FILES_PER_COMPLETION } from "./media-upload"; import type { Env } from "../types"; @@ -13,11 +14,26 @@ function mediaResponse(sizeBytes = 9, body: BodyInit = "png-bytes"): Response { }); } -function makeEnv(fetchMedia: () => Promise = async () => mediaResponse()): Env { +function uploadTicket(fileId = "F1"): Response { + return Response.json({ + ok: true, + upload_url: `https://files.slack.com/upload/${fileId}`, + file_id: fileId, + }); +} + +function makeEnv( + fetchMedia: () => Promise = async () => mediaResponse(), + fetchAccess: () => Promise = async () => Response.json({ artifacts: [] }) +): Env { return { - SLACK_KV: {} as KVNamespace, + SLACK_KV: { get: vi.fn(async () => null) } as unknown as KVNamespace, SLACK_COMPLETION_QUEUE: {} as Queue, - CONTROL_PLANE: { fetch: vi.fn(fetchMedia) } as unknown as Fetcher, + CONTROL_PLANE: { + fetch: vi.fn(async (input: RequestInfo | URL) => + new URL(String(input)).pathname.endsWith("/artifacts") ? fetchAccess() : fetchMedia() + ), + } as unknown as Fetcher, DEPLOYMENT_NAME: "test", CONTROL_PLANE_URL: "https://control-plane.test", WEB_APP_URL: "https://app.test", @@ -47,40 +63,104 @@ function input(env: Env, artifacts: MediaArtifactInfo[]) { threadTs: "111.222", artifacts, traceId: "trace-1", + onShareAttempt: vi.fn(), }; } describe("deliverMediaArtifacts", () => { + it.each([ + ["media", 404], + ["ticket", 403], + ["upload", 503], + ] as const)( + "stops the batch without sharing staged files after a %s failure and proof status %s", + async (stage, status) => { + const env = makeEnv(undefined, async () => new Response(null, { status })); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(uploadTicket()) + .mockResolvedValueOnce(new Response("OK")); + if (stage === "media") { + vi.mocked(env.CONTROL_PLANE.fetch) + .mockResolvedValueOnce(mediaResponse()) + .mockResolvedValueOnce(new Response(null, { status: 404 })); + } else { + fetch.mockResolvedValueOnce( + stage === "ticket" + ? Response.json({ ok: false, error: "missing_scope" }) + : uploadTicket("F2") + ); + if (stage === "upload") fetch.mockRejectedValueOnce(new Error("temporary upload failure")); + } + const delivery = input(env, [IMAGE, { ...IMAGE, id: "denied" }, { ...IMAGE, id: "later" }]); + const pending = deliverMediaArtifacts(delivery); + await expect(pending).rejects.toBeInstanceOf(ProtectedReadError); + await expect(pending).rejects.toMatchObject({ + kind: status === 503 ? "unavailable" : "denied", + }); + expect(delivery.onShareAttempt).not.toHaveBeenCalled(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(3); + expect(new URL(String(vi.mocked(env.CONTROL_PLANE.fetch).mock.calls[2]?.[0])).pathname).toBe( + "/sessions/session-1/artifacts" + ); + expect( + fetch.mock.calls.some(([url]) => String(url).includes("files.completeUploadExternal")) + ).toBe(false); + } + ); + + it.each([true, false])("does not share files when closure is initial=%s", async (initial) => { + const env = makeEnv(); + const get = vi.fn().mockResolvedValue({ + sessionId: "session-1", + repoId: "acme/app", + repoFullName: "acme/app", + model: "openai/gpt-5.4", + createdAt: 1, + closed: true, + }); + if (!initial) get.mockResolvedValueOnce(null); + env.SLACK_KV = { get } as unknown as KVNamespace; + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(uploadTicket()) + .mockResolvedValueOnce(new Response("OK")); + const delivery = input(env, [IMAGE]); + expect((await deliverMediaArtifacts(delivery)).uploaded).toBe(0); + expect(delivery.onShareAttempt).not.toHaveBeenCalled(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(initial ? 0 : 1); + expect(fetch).toHaveBeenCalledTimes(initial ? 0 : 2); + expect( + fetch.mock.calls.some(([url]) => String(url).includes("files.completeUploadExternal")) + ).toBe(false); + }); + it("stages files serially and finalizes them in one ordered call", async () => { const env = makeEnv(); + const delivery = input(env, [IMAGE, { ...IMAGE, id: "image-2", caption: "Forecast" }]); const slackFetch = vi .spyOn(globalThis, "fetch") - .mockResolvedValueOnce( - Response.json({ - ok: true, - upload_url: "https://files.slack.com/upload/v1/one", - file_id: "F1", - }) - ) + .mockResolvedValueOnce(uploadTicket()) .mockResolvedValueOnce(new Response("OK")) - .mockResolvedValueOnce( - Response.json({ - ok: true, - upload_url: "https://files.slack.com/upload/v1/two", - file_id: "F2", - }) - ) + .mockResolvedValueOnce(uploadTicket("F2")) .mockResolvedValueOnce(new Response("OK")) - .mockResolvedValueOnce(Response.json({ ok: true, files: [{ id: "F1" }, { id: "F2" }] })); + .mockImplementationOnce(async () => { + expect(delivery.onShareAttempt).toHaveBeenCalledOnce(); + return Response.json({ ok: true, files: [{ id: "F1" }, { id: "F2" }] }); + }); - const result = await deliverMediaArtifacts( - input(env, [IMAGE, { ...IMAGE, id: "image-2", caption: "Forecast" }]) - ); + const result = await deliverMediaArtifacts(delivery); expect(result).toEqual({ uploaded: 2, failed: 0, omitted: 0 }); + expect(delivery.onShareAttempt).toHaveBeenCalledOnce(); expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); - expect(slackFetch.mock.calls[1]?.[0]).toBe("https://files.slack.com/upload/v1/one"); - expect(slackFetch.mock.calls[3]?.[0]).toBe("https://files.slack.com/upload/v1/two"); + for (const [url, init] of vi.mocked(env.CONTROL_PLANE.fetch).mock.calls) { + expect(new URL(String(url)).searchParams.get("channel")).toBe("slack:C123"); + expect(new URL(String(url)).searchParams.get("purpose")).toBe("slack-post"); + expect(new Headers(init?.headers).get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + } + expect(slackFetch.mock.calls[1]?.[0]).toBe("https://files.slack.com/upload/F1"); + expect(slackFetch.mock.calls[3]?.[0]).toBe("https://files.slack.com/upload/F2"); const completeCalls = slackFetch.mock.calls.filter(([url]) => String(url).includes("files.completeUploadExternal") ); @@ -116,7 +196,12 @@ describe("deliverMediaArtifacts", () => { failed: SLACK_MEDIA_MAX_FILES_PER_COMPLETION, omitted: 2, }); - expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(SLACK_MEDIA_MAX_FILES_PER_COMPLETION); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2 * SLACK_MEDIA_MAX_FILES_PER_COMPLETION); + expect( + vi + .mocked(env.CONTROL_PLANE.fetch) + .mock.calls.filter(([url]) => new URL(String(url)).pathname.endsWith("/artifacts")) + ).toHaveLength(SLACK_MEDIA_MAX_FILES_PER_COMPLETION); }); it("skips known oversized media without fetching it", async () => { @@ -140,15 +225,18 @@ describe("deliverMediaArtifacts", () => { }) ); - const result = await deliverMediaArtifacts(input(env, [IMAGE])); - - expect(result).toEqual({ uploaded: 0, failed: 1, omitted: 0 }); + await expect(deliverMediaArtifacts(input(env, [IMAGE]))).resolves.toEqual({ + uploaded: 0, + failed: 1, + omitted: 0, + }); expect(cancel).toHaveBeenCalledOnce(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); }); it("counts failed upload attempts toward the total byte limit", async () => { const tenMiB = 10 * 1024 * 1024; - const env = makeEnv(async () => mediaResponse(tenMiB)); + const env = makeEnv(async () => mediaResponse(tenMiB, new Uint8Array(tenMiB))); vi.spyOn(globalThis, "fetch").mockResolvedValue( Response.json({ ok: false, error: "missing_scope" }) ); @@ -162,21 +250,16 @@ describe("deliverMediaArtifacts", () => { ); expect(result).toEqual({ uploaded: 0, failed: 2, omitted: 1 }); - expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(4); }); it("finalizes the successful subset when another artifact fails", async () => { const env = makeEnv(); const slackFetch = vi .spyOn(globalThis, "fetch") - .mockResolvedValueOnce(Response.json({ ok: false, error: "missing_scope" })) - .mockResolvedValueOnce( - Response.json({ - ok: true, - upload_url: "https://files.slack.com/upload/v1/two", - file_id: "F2", - }) - ) + .mockResolvedValueOnce(uploadTicket()) + .mockRejectedValueOnce(new Error("temporary file upload failure")) + .mockResolvedValueOnce(uploadTicket("F2")) .mockResolvedValueOnce(new Response("OK")) .mockResolvedValueOnce(Response.json({ ok: true, files: [{ id: "F2" }] })); @@ -185,6 +268,10 @@ describe("deliverMediaArtifacts", () => { ); expect(result).toEqual({ uploaded: 1, failed: 1, omitted: 0 }); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(3); + expect(new URL(String(vi.mocked(env.CONTROL_PLANE.fetch).mock.calls[1]?.[0])).pathname).toBe( + "/sessions/session-1/artifacts" + ); const completeCall = slackFetch.mock.calls.find(([url]) => String(url).includes("files.completeUploadExternal") ); @@ -194,31 +281,101 @@ describe("deliverMediaArtifacts", () => { it("reports every staged file as failed when finalization fails", async () => { const env = makeEnv(); + const delivery = input(env, [IMAGE, { ...IMAGE, id: "image-2" }]); + let shareReportedBeforeFinalization = false; vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce( - Response.json({ - ok: true, - upload_url: "https://files.slack.com/upload/v1/one", - file_id: "F1", - }) - ) + .mockResolvedValueOnce(uploadTicket()) + .mockResolvedValueOnce(new Response("OK")) + .mockResolvedValueOnce(uploadTicket("F2")) .mockResolvedValueOnce(new Response("OK")) - .mockResolvedValueOnce(Response.json({ ok: false, error: "internal_error" })); + .mockImplementationOnce(async () => { + shareReportedBeforeFinalization = delivery.onShareAttempt.mock.calls.length === 1; + throw new Error("response lost after accepted share"); + }); - const result = await deliverMediaArtifacts(input(env, [IMAGE])); + const result = await deliverMediaArtifacts(delivery); - expect(result).toEqual({ uploaded: 0, failed: 1, omitted: 0 }); + expect(shareReportedBeforeFinalization).toBe(true); + expect(delivery.onShareAttempt).toHaveBeenCalledOnce(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); + expect(result).toEqual({ uploaded: 0, failed: 2, omitted: 0 }); }); - it("isolates unexpected media retrieval errors", async () => { - const env = makeEnv(async () => { - throw new Error("binding unavailable"); + it.each(["network", "body-network", "truncated", "oversized-body"])( + "counts a failed media read after a fresh allowed publication proof: %s", + async (failure) => { + const env = makeEnv(async () => { + if (failure === "network") throw new Error("binding unavailable"); + if (failure === "body-network") + return mediaResponse( + 9, + new ReadableStream({ + start(controller) { + controller.error(new Error("media download interrupted")); + }, + }) + ); + if (failure === "truncated") return mediaResponse(9, "partial"); + return mediaResponse(1); + }); + const delivery = input(env, [IMAGE]); + const fetch = vi.spyOn(globalThis, "fetch"); + await expect(deliverMediaArtifacts(delivery)).resolves.toEqual({ + uploaded: 0, + failed: 1, + omitted: 0, + }); + expect(delivery.onShareAttempt).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(2); + } + ); + + it("rechecks access before continuing the batch or sharing files after a missing artifact", async () => { + let proofAllowed = false; + const env = makeEnv(undefined, async () => { + proofAllowed = true; + return Response.json({ artifacts: [] }); }); + vi.mocked(env.CONTROL_PLANE.fetch) + .mockResolvedValueOnce(mediaResponse()) + .mockResolvedValueOnce(new Response(null, { status: 404 })); + const delivery = input(env, [IMAGE, { ...IMAGE, id: "missing" }, { ...IMAGE, id: "later" }]); + delivery.onShareAttempt.mockImplementation(() => expect(proofAllowed).toBe(true)); + const slackFetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(uploadTicket()) + .mockResolvedValueOnce(new Response("OK")) + .mockImplementationOnce(async () => { + expect(proofAllowed).toBe(true); + return uploadTicket("F3"); + }) + .mockResolvedValueOnce(new Response("OK")) + .mockImplementationOnce(async () => { + expect(proofAllowed).toBe(true); + expect(delivery.onShareAttempt).toHaveBeenCalledOnce(); + return Response.json({ ok: true, files: [{ id: "F1" }, { id: "F3" }] }); + }); - await expect(deliverMediaArtifacts(input(env, [IMAGE]))).resolves.toEqual({ - uploaded: 0, + await expect(deliverMediaArtifacts(delivery)).resolves.toEqual({ + uploaded: 2, failed: 1, omitted: 0, }); + expect( + vi.mocked(env.CONTROL_PLANE.fetch).mock.calls.map(([url]) => new URL(String(url)).pathname) + ).toEqual([ + "/sessions/session-1/media/image-1", + "/sessions/session-1/media/missing", + "/sessions/session-1/artifacts", + "/sessions/session-1/media/later", + ]); + const completeCall = slackFetch.mock.calls.find(([url]) => + String(url).includes("files.completeUploadExternal") + ); + expect(JSON.parse(String(completeCall?.[1]?.body)).files).toEqual([ + { id: "F1", title: "Revenue chart" }, + { id: "F3", title: "Revenue chart" }, + ]); }); }); diff --git a/packages/slack-bot/src/completion/media-upload.ts b/packages/slack-bot/src/completion/media-upload.ts index 0731f0df14..40bf897ff6 100644 --- a/packages/slack-bot/src/completion/media-upload.ts +++ b/packages/slack-bot/src/completion/media-upload.ts @@ -4,10 +4,14 @@ import { uploadToExternalUrl, } from "@open-inspect/shared/slack"; import type { MediaArtifactInfo } from "@open-inspect/shared/types/artifacts"; +import { ProtectedReadError } from "@open-inspect/shared/completion/extractor"; +import { readBodyCapped } from "@open-inspect/shared/http-body"; import type { Env } from "../types"; import { signedControlPlaneFetch } from "../internal-auth"; import { createLogger } from "../logger"; import { OUTBOUND_REQUEST_TIMEOUT_MS } from "../request-options"; +import { checkPublicationAccess } from "../sessions/control-plane-client"; +import { isThreadSessionClosed } from "../sessions/thread-session-store"; export const SLACK_MEDIA_MAX_FILES_PER_COMPLETION = 5; export const SLACK_MEDIA_MAX_FILE_BYTES = 10 * 1024 * 1024; @@ -37,6 +41,8 @@ interface DeliverMediaArtifactsInput { threadTs: string; artifacts: MediaArtifactInfo[]; traceId?: string; + /** Called before sharing files, even if Slack's response is lost or unsuccessful. */ + onShareAttempt: () => void; } type StagedFile = { id: string; title: string }; @@ -57,6 +63,8 @@ export async function deliverMediaArtifacts( failed: 0, omitted: uniqueArtifacts.length - selected.length, }; + if (await isThreadSessionClosed(input.env, input.channel, input.threadTs, input.sessionId)) + return result; const staged: StagedFile[] = []; let attemptedBytes = 0; @@ -74,28 +82,47 @@ export async function deliverMediaArtifacts( try { stage = await stageArtifact(input, artifact, attemptedBytes); } catch (error) { - log.warn("slack.media.delivery", { + log.warn("slack.media.stage", { + trace_id: input.traceId, + session_id: input.sessionId, + message_id: input.messageId, artifact_id: artifact.id, outcome: "error", - error: error instanceof Error ? error : String(error), + error: error instanceof Error ? error : new Error(String(error)), }); stage = { kind: "failed" }; } - if (stage.kind === "omitted") { result.omitted += 1; continue; } if (stage.sizeBytes !== undefined) attemptedBytes += stage.sizeBytes; if (stage.kind === "failed") { + // Failed staging may conceal revoked access to staged files or already-extracted text. + const access = await checkPublicationAccess( + input.env, + input.sessionId, + input.channel, + input.traceId + ); + if (access !== "allowed") + throw new ProtectedReadError( + `Control plane publication access ${access}`, + access === "denied" ? 403 : undefined + ); result.failed += 1; continue; } staged.push(stage.file); } - if (staged.length === 0) return result; + if ( + staged.length === 0 || + (await isThreadSessionClosed(input.env, input.channel, input.threadTs, input.sessionId)) + ) + return result; + input.onShareAttempt(); const complete = await completeExternalUpload(input.env.SLACK_BOT_TOKEN, { files: staged, channelId: input.channel, @@ -141,16 +168,30 @@ async function stageArtifact( artifact_id: artifact.id, artifact_type: artifact.type, }; - const mediaUrl = `https://internal/sessions/${encodeURIComponent(input.sessionId)}/media/${encodeURIComponent(artifact.id)}`; - const response = await signedControlPlaneFetch( - input.env, - { method: "GET", url: mediaUrl, traceId: input.traceId }, - { signal: AbortSignal.timeout(OUTBOUND_REQUEST_TIMEOUT_MS) } + const mediaUrl = new URL( + `https://internal/sessions/${encodeURIComponent(input.sessionId)}/media/${encodeURIComponent(artifact.id)}` ); + mediaUrl.searchParams.set("channel", `slack:${input.channel}`); + mediaUrl.searchParams.set("purpose", "slack-post"); + let response: Response; + try { + response = await signedControlPlaneFetch( + input.env, + { method: "GET", url: mediaUrl.toString(), traceId: input.traceId }, + { signal: AbortSignal.timeout(OUTBOUND_REQUEST_TIMEOUT_MS) } + ); + } catch (error) { + throw new ProtectedReadError("Control plane media read unavailable", undefined, { + cause: error, + }); + } if (!response.ok || !response.body) { await cancelBody(response.body); log.warn("slack.media.fetch", { ...base, outcome: "error", http_status: response.status }); - return { kind: "failed" }; + throw new ProtectedReadError( + `Control plane media read failed: ${response.status}`, + response.status + ); } const mimeType = response.headers.get("Content-Type")?.split(";", 1)[0]?.trim() ?? ""; @@ -159,7 +200,7 @@ async function stageArtifact( if (!extension || !Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) { await cancelBody(response.body); log.warn("slack.media.fetch", { ...base, outcome: "error", error: "invalid_media_headers" }); - return { kind: "failed" }; + throw new ProtectedReadError("Invalid media response headers"); } if ( sizeBytes > SLACK_MEDIA_MAX_FILE_BYTES || @@ -170,6 +211,17 @@ async function stageArtifact( return { kind: "omitted" }; } + let bytes: Uint8Array | null; + try { + bytes = await readBodyCapped(response.body, sizeBytes); + } catch (error) { + throw new ProtectedReadError("Control plane media body read unavailable", undefined, { + cause: error, + }); + } + if (!bytes || bytes.byteLength !== sizeBytes) + throw new ProtectedReadError("Invalid media response length"); + const title = artifact.caption?.trim() || `${artifact.type} ${artifact.id}`; const ticket = await getExternalUploadUrl(input.env.SLACK_BOT_TOKEN, { filename: `artifact-${artifact.id}.${extension}`, @@ -178,7 +230,6 @@ async function stageArtifact( signal: AbortSignal.timeout(OUTBOUND_REQUEST_TIMEOUT_MS), }); if (!ticket.ok) { - await cancelBody(response.body); log.warn("slack.media.get_upload_url", { ...base, outcome: "error", @@ -189,12 +240,11 @@ async function stageArtifact( const upload = await uploadToExternalUrl( ticket.upload_url, - response.body, + bytes, mimeType, AbortSignal.timeout(OUTBOUND_REQUEST_TIMEOUT_MS) ); if (!upload.ok) { - await cancelBody(response.body); log.warn("slack.media.upload_bytes", { ...base, outcome: "error", slack_error: upload.error }); return { kind: "failed", sizeBytes }; } @@ -207,6 +257,6 @@ async function cancelBody(body: ReadableStream | null): Promise { try { await body.cancel(); } catch { - // The upload fetch may already own or consume the stream. + // Cancellation must not change the read failure classification. } } diff --git a/packages/slack-bot/src/events/message-handler.ts b/packages/slack-bot/src/events/message-handler.ts index 3b761951da..7a223f5c7e 100644 --- a/packages/slack-bot/src/events/message-handler.ts +++ b/packages/slack-bot/src/events/message-handler.ts @@ -14,6 +14,7 @@ import { type SlackImageAttachment, } from "../attachments"; import { createClassifier } from "../classifier"; +import { getChannelBinding, resolveChannelBinding } from "../channel-bindings"; import { loadTargetCatalog } from "../classifier/catalog"; import { stripMentions } from "../dm-utils"; import { @@ -36,6 +37,7 @@ import { } from "../messages/context"; import { storePendingRequest } from "../pending-requests/pending-request-store"; import { deliverPrompt } from "../sessions/prompt-delivery"; +import { checkPublicationAccess } from "../sessions/control-plane-client"; import { loadAuthoritativeSlackLaunchSettings, startSessionAndSendPrompt, @@ -43,17 +45,18 @@ import { } from "../sessions/session-launcher"; import { advanceLastPromptTs, - clearThreadSession, + closeThreadSession, lookupThreadSession, + reopenThreadSession, + THREAD_CLOSED_MESSAGE, } from "../sessions/thread-session-store"; import { buildTargetClarificationBlocks, getTargetCatalogNotice } from "../target-clarification"; import { targetId } from "../targets"; -import type { BackgroundTaskScheduler, Env } from "../types"; +import type { BackgroundTaskScheduler, Env, ThreadSession } from "../types"; import { resolveSlackActorIdentity, type SlackActorIdentity } from "../user-identity"; import { EMPTY_INLINE_PROMPT_OPTIONS, hasInlinePromptOptions, - normalizeModelSelection, parseInlinePromptFlags, resolveInlinePromptOptions, type InlinePromptOptions, @@ -80,6 +83,7 @@ function hasRunnableContent(content: IncomingMessageContent): boolean { interface IncomingMessageParams { content: IncomingMessageContent; + existingSession: ThreadSession | null; user: string; channel: string; ts: string; @@ -91,6 +95,44 @@ interface IncomingMessageParams { scheduleBackground: BackgroundTaskScheduler; } +async function resolveExistingThreadSession( + env: Env, + channel: string, + threadTs: string | undefined, + traceId?: string +): Promise { + if (!threadTs) return null; + let session = await lookupThreadSession(env, channel, threadTs); + if (!session) return null; + const binding = await getChannelBinding(env, channel, traceId).catch((error) => { + log.warn("channel_binding.followup_unavailable", { trace_id: traceId, channel, error }); + return null; + }); + // Legacy mappings predate team ownership and represent workspace sessions. + const bindingMatches = binding !== null && binding.teamId === (session.teamId ?? null); + if (!session.closed && !bindingMatches) { + await closeThreadSession(env, channel, threadTs, session.sessionId); + session = { ...session, closed: true }; + } else if ( + session.closed && + bindingMatches && + // Bindings and visibility can change back, so a reply re-checks a closure live. + (await checkPublicationAccess(env, session.sessionId, channel, traceId)) === "allowed" + ) { + session = await reopenThreadSession(env, channel, threadTs, session); + log.info("thread_session.reopened", { + trace_id: traceId, + session_id: session.sessionId, + channel, + thread_ts: threadTs, + }); + } + if (session.closed) { + await postMessage(env.SLACK_BOT_TOKEN, channel, THREAD_CLOSED_MESSAGE, { thread_ts: threadTs }); + } + return session; +} + /** * Route one user message: follow up on the thread's existing session when there * is one, otherwise classify the target and launch a new session (or ask for @@ -100,6 +142,7 @@ interface IncomingMessageParams { async function handleIncomingMessage(params: IncomingMessageParams): Promise { const { content, + existingSession, user, channel, ts, @@ -139,149 +182,155 @@ async function handleIncomingMessage(params: IncomingMessageParams): Promise { + const existingSession = await resolveExistingThreadSession( + env, + event.channel, + event.thread_ts, + traceId + ); + if (existingSession?.closed) return; const parsedFlags = parseInlinePromptFlags(stripMentions(event.text)); const messageText = parsedFlags.ok ? parsedFlags.text : ""; const threadKey = event.thread_ts || event.ts; @@ -518,6 +584,7 @@ export async function handleAppMention( } await handleIncomingMessage({ content, + existingSession, user: event.user, channel: event.channel, ts: event.ts, @@ -548,6 +615,13 @@ export async function handleDirectMessage( scheduleBackground: BackgroundTaskScheduler ): Promise { log.info("slack.dm.received", { trace_id: traceId, user: event.user, channel: event.channel }); + const existingSession = await resolveExistingThreadSession( + env, + event.channel, + event.thread_ts, + traceId + ); + if (existingSession?.closed) return; const parsedFlags = parseInlinePromptFlags(stripMentions(event.text)); const messageText = parsedFlags.ok ? parsedFlags.text : ""; const forwarded = collectForwardedMessages(event.attachments); @@ -564,6 +638,7 @@ export async function handleDirectMessage( scheduleStartingStatus(scheduleBackground, env, event.channel, threadKey, traceId); await handleIncomingMessage({ content, + existingSession, user: event.user, channel: event.channel, ts: event.ts, diff --git a/packages/slack-bot/src/index.test.ts b/packages/slack-bot/src/index.test.ts index f97eda43c0..e48b3327a3 100644 --- a/packages/slack-bot/src/index.test.ts +++ b/packages/slack-bot/src/index.test.ts @@ -38,6 +38,8 @@ vi.mock("@open-inspect/shared/slack", async () => { import app from "./index"; import { clearBotUserIdCache } from "./bot-identity"; import { clearLocalCache } from "./classifier/repos"; +import { clearEnvironmentsLocalCache } from "./classifier/environments"; +import { RepoClassifier } from "./classifier"; function createMockKV() { const store = new Map(); @@ -92,6 +94,8 @@ function makeEnv() { const controlPlaneFetch = vi.fn(); controlPlaneFetch.mockImplementation(async (input) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) return Response.json({ teamId: null }); + if (url.includes("/environments")) return Response.json({ environments: [], total: 0 }); if (url.includes("/repos")) { return new Response( JSON.stringify( @@ -157,10 +161,17 @@ function buildNumberedRepos(count: number) { }); } -/** Point CONTROL_PLANE.fetch at a fixed repo list (other routes return enabledModels). */ -function mockReposFetch(env: ReturnType, repos: Array>) { +/** Point CONTROL_PLANE.fetch at a fixed catalog and binding scope. */ +function mockReposFetch( + env: ReturnType, + repos: Array>, + teamId: string | null = null +) { env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) + return Response.json(teamId ? { teamId, kind: "primary" } : { teamId: null }); + if (url.includes("/environments")) return Response.json({ environments: [], total: 0 }); if (url.includes("/repos")) { return new Response(JSON.stringify(mockReposResponseBody(repos)), { status: 200, @@ -195,6 +206,8 @@ function makeSessionEnv( session?: unknown; prompt?: unknown | unknown[]; promptStatus?: number | number[]; + publicationStatus?: number; + teamId?: string | null; modelPreferencesStatus?: number; } = {} ): ReturnType { @@ -202,6 +215,14 @@ function makeSessionEnv( let promptResponseIndex = 0; env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) + return Response.json( + responses.teamId ? { teamId: responses.teamId, kind: "primary" } : { teamId: null } + ); + if (url.includes("/environments")) return Response.json({ environments: [], total: 0 }); + if (new URL(url).pathname.endsWith("/artifacts")) { + return Response.json({ artifacts: [] }, { status: responses.publicationStatus ?? 200 }); + } if (url.includes("/repos")) { order.push("repos"); return new Response( @@ -431,11 +452,24 @@ function slackEventRequest(event: Record, eventId = crypto.rand }); } +function slackInteractionRequest(payload: Record): Request { + return new Request("http://localhost/interactions", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "x-slack-signature": "v0=test", + "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, + }, + body: new URLSearchParams({ payload: JSON.stringify(payload) }), + }); +} + describe("POST /events", () => { beforeEach(() => { vi.clearAllMocks(); clearBotUserIdCache(); clearLocalCache(); + clearEnvironmentsLocalCache(); mockVerifySlackSignature.mockResolvedValue(true); mockGetUserInfo.mockResolvedValue({ ok: false, error: "user_not_found" }); mockMessagesCreate.mockResolvedValue({ @@ -535,7 +569,7 @@ describe("POST /events", () => { it("sets Starting status for a new app mention before session creation", async () => { const order: string[] = []; const slackFetch = mockSlackFetch(order); - const env = makeSessionEnv(order); + const env = makeSessionEnv(order, { teamId: "team-a" }); const ctx = makeCtx(); const response = await app.fetch( @@ -564,7 +598,6 @@ describe("POST /events", () => { loading_messages: ["Starting..."], }); expect(startingStatusBodies(slackFetch)).toHaveLength(3); - expect(order.indexOf("status")).toBeLessThan(order.indexOf("channelInfo")); expect(order.indexOf("status")).toBeLessThan(order.indexOf("session")); expect(mockGetUserInfo).toHaveBeenCalledOnce(); @@ -572,6 +605,19 @@ describe("POST /events", () => { expect(postBodies.some((body) => String(body.text).includes("Session started!"))).toBe(false); const sessionBodies = sessionFetchBodies(env.CONTROL_PLANE.fetch); + expect(sessionBodies).toEqual([expect.objectContaining({ teamId: "team-a" })]); + for (const resource of ["repos", "environments"]) { + const catalogReads = env.CONTROL_PLANE.fetch.mock.calls.filter( + ([url]) => new URL(String(url)).pathname === `/${resource}` + ); + expect(catalogReads).toHaveLength(1); + for (const [url, init] of catalogReads) { + expect(String(url)).toBe(`https://internal/${resource}?channel=slack%3AC123`); + const headers = new Headers(init?.headers); + expect(headers.get("X-OpenInspect-Actor")).toBe("slack:U123"); + expect(headers.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + } + } expect(sessionBodies[0]).not.toHaveProperty("title"); expect((env.SLACK_KV as unknown as { put: ReturnType }).put).toHaveBeenCalledWith( "thread:C123:111.222", @@ -616,6 +662,9 @@ describe("POST /events", () => { }); env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) + return Response.json({ teamId: "team-a", kind: "primary" }); + if (url.includes("/environments")) return Response.json({ environments: [], total: 0 }); if (url.includes("/repos")) { return new Response( JSON.stringify( @@ -728,6 +777,7 @@ describe("POST /events", () => { ).resolves.toEqual( expect.objectContaining({ requestId, + teamId: "team-a", channel: "C123", threadTs: "111.222", message: "frontend backend help", @@ -772,6 +822,26 @@ describe("POST /events", () => { expect(selectionResponse.status).toBe(200); await flushWaitUntil(selectionCtx); expect(mockGetUserInfo).toHaveBeenCalledOnce(); + expect(sessionFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([ + expect.objectContaining({ teamId: "team-a" }), + ]); + for (const resource of ["repos", "environments"]) { + const catalogReads = env.CONTROL_PLANE.fetch.mock.calls.filter( + ([url]) => new URL(String(url)).pathname === `/${resource}` + ); + expect(catalogReads).toHaveLength(resource === "repos" ? 3 : 2); + for (const [url, init] of catalogReads) { + expect(String(url)).toBe(`https://internal/${resource}?channel=slack%3AC123`); + const headers = new Headers(init?.headers); + expect(headers.get("X-OpenInspect-Actor")).toBe("slack:U123"); + expect(headers.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + } + } + expect( + env.CONTROL_PLANE.fetch.mock.calls.filter(([url]) => + String(url).includes("/channel-bindings/slack/C123") + ) + ).toHaveLength(2); expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([ expect.objectContaining({ content: expect.stringContaining("[Ajan (U123)]: frontend backend help"), @@ -806,9 +876,17 @@ describe("POST /events", () => { expect(order).not.toContain("prompt"); expect(slackApiBodies(slackFetch, "chat.postMessage")).toEqual( expect.arrayContaining([ - expect.objectContaining({ text: "Sorry, I couldn't create a session. Please try again." }), + expect.objectContaining({ + channel: "C123", + thread_ts: "111.222", + text: "Sorry, I couldn't create a session. Please try again.", + }), ]) ); + const threadMappingWrite = ( + env.SLACK_KV as unknown as { put: ReturnType } + ).put.mock.calls.find(([key]) => key === "thread:C123:111.222"); + expect(threadMappingWrite).toBeUndefined(); slackFetch.mockRestore(); }); @@ -839,6 +917,8 @@ describe("POST /events", () => { expect(slackApiBodies(slackFetch, "chat.postMessage")).toEqual( expect.arrayContaining([ expect.objectContaining({ + channel: "C123", + thread_ts: "111.222", text: "Session created but failed to send prompt. Please try again.", }), ]) @@ -881,7 +961,349 @@ describe("POST /events", () => { }); expect(startingStatusBodies(slackFetch)).toHaveLength(3); expect(order.indexOf("status")).toBeLessThan(order.indexOf("session")); + expect(sessionFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([ + expect.objectContaining({ teamId: null }), + ]); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledWith( + "https://internal/channel-bindings/slack/D123", + expect.anything() + ); + for (const resource of ["repos", "environments"]) { + const catalogReads = env.CONTROL_PLANE.fetch.mock.calls.filter( + ([url]) => new URL(String(url)).pathname === `/${resource}` + ); + expect(catalogReads).toHaveLength(1); + for (const [url, init] of catalogReads) { + expect(String(url)).toBe(`https://internal/${resource}?channel=slack%3AD123`); + const headers = new Headers(init?.headers); + expect(headers.get("X-OpenInspect-Actor")).toBe("slack:U123"); + expect(headers.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + } + } + + slackFetch.mockRestore(); + }); + + it.each([404, 503, "malformed", "network"] as const)( + "refuses a new request before classification when binding lookup fails: %s", + async (failure) => { + const slackFetch = mockSlackFetch(); + const classify = vi.spyOn(RepoClassifier.prototype, "classify"); + const env = makeSessionEnv(); + env.CONTROL_PLANE.fetch.mockImplementation(async () => { + if (failure === "network") throw new Error("offline"); + return failure === "malformed" + ? Response.json({ teamId: null, kind: "primary" }) + : new Response(null, { status: failure }); + }); + const ctx = makeCtx(); + await app.fetch( + slackEventRequest({ + type: "message", + channel_type: "im", + channel: "D123", + text: "Fix it", + user: "U123", + ts: "111.222", + }), + env, + ctx + ); + await flushWaitUntil(ctx); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledOnce(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledWith( + "https://internal/channel-bindings/slack/D123", + expect.objectContaining({ + headers: expect.objectContaining({ "X-OpenInspect-Service": "slack-bot" }), + }) + ); + expect(mockMessagesCreate).not.toHaveBeenCalled(); + expect(classify).not.toHaveBeenCalled(); + expect(sessionFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(slackApiBodies(slackFetch, "chat.postMessage")).toEqual([ + expect.objectContaining({ + channel: "D123", + thread_ts: "111.222", + text: expect.stringContaining(failure === 404 ? "bind" : "verify"), + }), + ]); + classify.mockRestore(); + slackFetch.mockRestore(); + } + ); + + describe.each(["app_mention", "message"] as const)("mapped %s follow-up scope", (type) => { + it.each([ + ["rebound", "team-a"], + ["unbound", "team-a"], + ["missing", "team-a"], + ["malformed", "team-a"], + ["unavailable", "team-a"], + ["network", "team-a"], + ["rebound", null], + ["rebound", undefined], + ] as const)( + "tombstones before Slack reads when binding is %s and stored team is %s", + async (failure, teamId) => { + const channel = type === "message" ? "D123" : "C123"; + const slackFetch = mockSlackFetch(); + const classify = vi.spyOn(RepoClassifier.prototype, "classify"); + const env = makeSessionEnv(); + await env.SLACK_KV.put( + `thread:${channel}:111.222`, + JSON.stringify({ + sessionId: "team-a-session", + teamId, + repoId: "acme/app", + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + createdAt: 1, + lastPromptTs: "111.222", + }) + ); + env.CONTROL_PLANE.fetch.mockImplementation(async () => { + if (failure === "network") throw new Error("offline"); + if (failure === "missing") return new Response(null, { status: 404 }); + if (failure === "unavailable") return new Response(null, { status: 503 }); + return Response.json( + failure === "rebound" + ? { teamId: "team-b", kind: "source" } + : failure === "unbound" + ? { teamId: null } + : { teamId: "team-a" } + ); + }); + const ctx = makeCtx(); + await app.fetch( + slackEventRequest({ + type, + channel, + channel_type: type === "message" ? "im" : undefined, + text: "<@B123> confidential team-b follow-up", + user: "U123", + ts: "333.444", + thread_ts: "111.222", + files: [ + { + id: "F1", + name: "secret.png", + mimetype: "image/png", + url_private: "https://files.slack.com/secret.png", + size: 16, + }, + ], + }), + env, + ctx + ); + await flushWaitUntil(ctx); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledOnce(); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledWith( + `https://internal/channel-bindings/slack/${channel}`, + expect.anything() + ); + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(sessionFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(classify).not.toHaveBeenCalled(); + expect(mockGetUserInfo).not.toHaveBeenCalled(); + expect( + slackFetch.mock.calls.every(([url]) => String(url).includes("chat.postMessage")) + ).toBe(true); + expect(slackApiBodies(slackFetch, "chat.postMessage")).toEqual([ + expect.objectContaining({ + channel, + thread_ts: "111.222", + text: "this session is no longer available from this channel", + }), + ]); + expect(await env.SLACK_KV.get(`thread-closed:${channel}:111.222:team-a-session`)).toBe("1"); + expect(await env.SLACK_KV.get(`thread:${channel}:111.222`, "json")).toMatchObject({ + closed: true, + }); + classify.mockRestore(); + slackFetch.mockRestore(); + } + ); + }); + + it.each(["prompt", "attachment"] as const)( + "tombstones a matching mapped thread if %s admission observes a rebind", + async (write) => { + const slackFetch = mockSlackFetch(); + const env = makeSessionEnv([], { teamId: "team-a" }); + const dispatch = env.CONTROL_PLANE.fetch.getMockImplementation()!; + let uploads = 0; + env.CONTROL_PLANE.fetch.mockImplementation(async (input, init) => { + const url = new URL(String(input)); + if (url.pathname.endsWith(write === "prompt" ? "/prompt" : "/attachments")) { + expect(url.searchParams.get("channel")).toBe("slack:C123"); + if (write === "attachment" && uploads++ === 0) { + return Response.json({ attachmentId: "att-1", mimeType: "image/png" }, { status: 201 }); + } + return Response.json({ code: "slack_channel_scope_denied" }, { status: 403 }); + } + return dispatch(input, init); + }); + await env.SLACK_KV.put( + "thread:C123:111.222", + JSON.stringify({ + sessionId: "team-a-session", + teamId: "team-a", + repoId: "acme/app", + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + createdAt: 1, + }) + ); + const ctx = makeCtx(); + await app.fetch( + slackEventRequest({ + type: "app_mention", + channel: "C123", + text: "<@B123> follow up", + user: "U123", + ts: "333.444", + thread_ts: "111.222", + files: + write === "attachment" + ? ["F1", "F2"].map((id) => ({ + id, + name: "secret.png", + mimetype: "image/png", + url_private: "https://files.slack.com/secret.png", + size: 16, + })) + : [], + }), + env, + ctx + ); + await flushWaitUntil(ctx); + expect(await env.SLACK_KV.get("thread-closed:C123:111.222:team-a-session")).toBe("1"); + if (write === "attachment") expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(slackApiBodies(slackFetch, "chat.postMessage")).toContainEqual( + expect.objectContaining({ text: "this session is no longer available from this channel" }) + ); + slackFetch.mockRestore(); + } + ); + + it("allows a mapped team follow-up only after reading the current binding", async () => { + const slackFetch = mockSlackFetch(); + const env = makeSessionEnv([], { teamId: "team-a" }); + await env.SLACK_KV.put( + "thread:C123:111.222", + JSON.stringify({ + sessionId: "team-a-session", + teamId: "team-a", + repoId: "acme/app", + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + createdAt: 1, + }) + ); + const ctx = makeCtx(); + await app.fetch( + slackEventRequest({ + type: "app_mention", + channel: "C123", + text: "<@B123> follow up", + user: "U123", + ts: "333.444", + thread_ts: "111.222", + }), + env, + ctx + ); + await flushWaitUntil(ctx); + expect(env.CONTROL_PLANE.fetch.mock.calls[0][0]).toBe( + "https://internal/channel-bindings/slack/C123" + ); + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toHaveLength(1); + expect(await env.SLACK_KV.get("thread-closed:C123:111.222:team-a-session")).toBeNull(); + slackFetch.mockRestore(); + }); + it("reopens a closed thread once its binding and visibility allow posting again", async () => { + const slackFetch = mockSlackFetch(); + const env = makeSessionEnv([], { teamId: "team-a" }); + await env.SLACK_KV.put( + "thread:C123:111.222", + JSON.stringify({ + sessionId: "team-a-session", + teamId: "team-a", + repoId: "acme/app", + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + createdAt: 1, + closed: true, + }) + ); + await env.SLACK_KV.put("thread-closed:C123:111.222:team-a-session", "1"); + await env.SLACK_KV.put("thread-closed:C123:111.222:team-a-session:notice", "1"); + const ctx = makeCtx(); + await app.fetch( + slackEventRequest({ + type: "app_mention", + channel: "C123", + text: "<@B123> follow up", + user: "U123", + ts: "333.444", + thread_ts: "111.222", + }), + env, + ctx + ); + await flushWaitUntil(ctx); + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toHaveLength(1); + expect(await env.SLACK_KV.get("thread-closed:C123:111.222:team-a-session")).toBeNull(); + expect(await env.SLACK_KV.get("thread-closed:C123:111.222:team-a-session:notice")).toBeNull(); + expect(await env.SLACK_KV.get("thread:C123:111.222", "json")).not.toHaveProperty("closed"); + expect(slackApiBodies(slackFetch, "chat.postMessage")).not.toContainEqual( + expect.objectContaining({ text: "this session is no longer available from this channel" }) + ); + slackFetch.mockRestore(); + }); + + it.each([ + ["the channel is bound to another team", { teamId: "team-b" }], + ["the session cannot post to the channel", { teamId: "team-a", publicationStatus: 403 }], + ] as const)("keeps a closed thread closed while %s", async (_reason, responses) => { + const slackFetch = mockSlackFetch(); + const env = makeSessionEnv([], responses); + await env.SLACK_KV.put( + "thread:C123:111.222", + JSON.stringify({ + sessionId: "team-a-session", + teamId: "team-a", + repoId: "acme/app", + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + createdAt: 1, + closed: true, + }) + ); + await env.SLACK_KV.put("thread-closed:C123:111.222:team-a-session", "1"); + const ctx = makeCtx(); + await app.fetch( + slackEventRequest({ + type: "app_mention", + channel: "C123", + text: "<@B123> follow up", + user: "U123", + ts: "333.444", + thread_ts: "111.222", + }), + env, + ctx + ); + await flushWaitUntil(ctx); + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(await env.SLACK_KV.get("thread-closed:C123:111.222:team-a-session")).toBe("1"); + expect(slackApiBodies(slackFetch, "chat.postMessage")).toContainEqual( + expect.objectContaining({ text: "this session is no longer available from this channel" }) + ); slackFetch.mockRestore(); }); @@ -1227,117 +1649,123 @@ describe("POST /events", () => { slackFetch.mockRestore(); }); - it("fetches thread history after an existing session proves stale", async () => { - const order: string[] = []; - const slackFetch = mockSlackFetch(order, { - threadMessages: [{ type: "message", text: "Earlier request", user: "U456", ts: "111.222" }], - }); - const env = makeSessionEnv(order, { - prompt: [{ error: "Session not found" }, { messageId: "msg-2" }], - promptStatus: [404, 200], - }); - await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( - "thread:C123:111.222", - JSON.stringify({ - sessionId: "stale-session", - repoId: "acme/app", - repoFullName: "acme/app", - model: "anthropic/claude-haiku-4-5", - createdAt: Date.now(), - }) - ); - const ctx = makeCtx(); - - const response = await app.fetch( - slackEventRequest({ - type: "app_mention", - text: "<@B123> now add coverage", - user: "U123", - channel: "C123", - ts: "333.444", - thread_ts: "111.222", - }), - env, - ctx - ); - - expect(response.status).toBe(200); - await flushWaitUntil(ctx); - - expect( - slackFetch.mock.calls.filter( - ([input]) => - String(input).includes("conversations.replies") && String(input).includes("limit=200") - ) - ).toHaveLength(1); - const promptBodies = promptFetchBodies(env.CONTROL_PLANE.fetch); - expect(promptBodies).toHaveLength(2); - expect(promptBodies[1].content).toContain("Context from the Slack thread"); - expect(promptBodies[1].content).toContain("Earlier request"); - const storedMapping = ( - env.SLACK_KV as unknown as { get: (key: string, type: string) => Promise } - ).get("thread:C123:111.222", "json"); - await expect(storedMapping).resolves.toEqual( - expect.objectContaining({ sessionId: "session-1" }) - ); - - slackFetch.mockRestore(); - }); - - it("keeps the thread's session defaults when replacing a stale session", async () => { - const slackFetch = mockSlackFetch(); - const env = makeSessionEnv([], { - prompt: [{ error: "Session not found" }, { messageId: "msg-2" }], - promptStatus: [404, 200], - }); - // "high" is not this model's default effort, so an App Home reset would - // show up as "max" on the replacement session. - await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( - "thread:C123:111.222", - JSON.stringify({ + it.each([ + [404, 404, true], + [403, 200, false], + [404, 200, false], + [404, 503, false], + ] as const)( + "closes a mapping only with channel-wide denial (prompt %s, publication %s, closed %s)", + async (promptStatus, publicationStatus, closed) => { + const slackFetch = mockSlackFetch(); + const classify = vi.spyOn(RepoClassifier.prototype, "classify"); + const env = makeSessionEnv([], { + prompt: [{ error: "Denied" }, { messageId: "authorized-prompt" }], + promptStatus: [promptStatus, 200], + publicationStatus, + teamId: "team-a", + }); + const mapping = { sessionId: "stale-session", repoId: "acme/app", repoFullName: "acme/app", model: "anthropic/claude-haiku-4-5", reasoningEffort: "high", + teamId: "team-a", createdAt: Date.now(), - }) - ); - const ctx = makeCtx(); - - const response = await app.fetch( - slackEventRequest({ + }; + await env.SLACK_KV.put("thread:C123:111.222", JSON.stringify(mapping)); + const event = { type: "app_mention", text: "<@B123> now add coverage", user: "U123", channel: "C123", ts: "333.444", thread_ts: "111.222", - }), - env, - ctx - ); - - expect(response.status).toBe(200); - await flushWaitUntil(ctx); - - expect(sessionFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([ - expect.objectContaining({ - model: "anthropic/claude-haiku-4-5", - reasoningEffort: "high", - }), - ]); - await expect( - (env.SLACK_KV as unknown as { get: (key: string, type: string) => Promise }).get( - "thread:C123:111.222", - "json" - ) - ).resolves.toEqual( - expect.objectContaining({ sessionId: "session-1", reasoningEffort: "high" }) - ); - - slackFetch.mockRestore(); - }); + }; + const ctx = makeCtx(); + expect((await app.fetch(slackEventRequest(event), env, ctx)).status).toBe(200); + await flushWaitUntil(ctx); + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toHaveLength(1); + await expect(env.SLACK_KV.get("thread:C123:111.222", "json")).resolves.toEqual( + closed ? expect.objectContaining({ ...mapping, closed: true }) : mapping + ); + const publication = env.CONTROL_PLANE.fetch.mock.calls.filter(([url]) => + String(url).includes("/artifacts") + ); + expect(publication).toHaveLength(promptStatus === 404 ? 1 : 0); + if (promptStatus === 404) { + const [url, init] = publication[0]!; + expect(new URL(String(url)).pathname).toBe("/sessions/stale-session/artifacts"); + expect(new URL(String(url)).searchParams.get("channel")).toBe("slack:C123"); + expect(new URL(String(url)).searchParams.get("purpose")).toBe("slack-post"); + expect(new Headers(init?.headers).get("X-OpenInspect-Actor")).toBeNull(); + } + const reply = + promptStatus === 403 + ? "you do not have access to this session" + : "this session is no longer available from this channel"; + expect(slackApiBodies(slackFetch, "chat.postMessage")).toEqual([ + expect.objectContaining({ channel: "C123", thread_ts: "111.222", text: reply }), + ]); + const requestCount = env.CONTROL_PLANE.fetch.mock.calls.length; + for (const text of closed ? ["<@B123> again", "<@B123>"] : ["<@B123> authorized follow-up"]) { + const next = makeCtx(); + await app.fetch( + slackEventRequest({ ...event, text, user: "U456", ts: "444.555" }), + env, + next + ); + await flushWaitUntil(next); + } + if (closed) { + // Each reply re-checks the closure live; denied publication keeps it closed. + expect( + env.CONTROL_PLANE.fetch.mock.calls + .slice(requestCount) + .map(([url]) => new URL(String(url)).pathname) + ).toEqual([ + "/channel-bindings/slack/C123", + "/sessions/stale-session/artifacts", + "/channel-bindings/slack/C123", + "/sessions/stale-session/artifacts", + ]); + expect(slackApiBodies(slackFetch, "chat.postMessage").map((body) => body.text)).toEqual([ + reply, + reply, + reply, + ]); + } else { + expect(promptFetchBodies(env.CONTROL_PLANE.fetch)).toHaveLength(2); + const [url, init] = env.CONTROL_PLANE.fetch.mock.calls.at(-1)!; + expect(new URL(String(url)).pathname).toBe("/sessions/stale-session/prompt"); + expect(new URL(String(url)).searchParams.get("channel")).toBe("slack:C123"); + expect(new Headers(init?.headers).get("X-OpenInspect-Actor")).toBe("slack:U456"); + const stored = await env.SLACK_KV.get>( + "thread:C123:111.222", + "json" + ); + expect(stored).toMatchObject(mapping); + expect(stored?.closed).not.toBe(true); + } + expect(sessionFetchBodies(env.CONTROL_PLANE.fetch)).toEqual([]); + expect(mockMessagesCreate).not.toHaveBeenCalled(); + expect(classify).not.toHaveBeenCalled(); + expect( + env.CONTROL_PLANE.fetch.mock.calls.some(([url]) => + /\/(repos|environments|integration-settings)(?:\/|\?|$)/.test(String(url)) + ) + ).toBe(false); + expect( + slackFetch.mock.calls.some( + ([url]) => + String(url).includes("conversations.replies") && String(url).includes("limit=200") + ) + ).toBe(false); + classify.mockRestore(); + slackFetch.mockRestore(); + } + ); it("forwards interim human messages on follow-ups to an existing session", async () => { const order: string[] = []; @@ -2024,6 +2452,7 @@ describe("POST /interactions", () => { beforeEach(() => { vi.clearAllMocks(); clearLocalCache(); + clearEnvironmentsLocalCache(); mockVerifySlackSignature.mockResolvedValue(true); mockOpenView.mockResolvedValue({ ok: true }); mockGetUserInfo.mockResolvedValue({ ok: false, error: "user_not_found" }); @@ -2053,15 +2482,7 @@ describe("POST /interactions", () => { }, ], }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const ctx = makeCtx(); const response = await app.fetch(request, env, ctx); @@ -2127,6 +2548,7 @@ describe("POST /interactions", () => { env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) return Response.json({ teamId: null }); if (url.includes("/repos")) { return new Response(JSON.stringify(mockReposResponseBody([])), { status: 200, @@ -2151,15 +2573,7 @@ describe("POST /interactions", () => { }, ], }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const ctx = makeCtx(); const response = await app.fetch(request, env, ctx); @@ -2195,15 +2609,7 @@ describe("POST /interactions", () => { }, }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const ctx = makeCtx(); @@ -2244,15 +2650,7 @@ describe("POST /interactions", () => { }, }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const ctx = makeCtx(); @@ -2290,15 +2688,7 @@ describe("POST /interactions", () => { }, }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const ctx = makeCtx(); @@ -2350,15 +2740,7 @@ describe("POST /interactions", () => { }, }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const ctx = makeCtx(); @@ -2398,15 +2780,7 @@ describe("POST /interactions", () => { }, }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const ctx = makeCtx(); @@ -2449,15 +2823,7 @@ describe("POST /interactions", () => { }, }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const ctx = makeCtx(); @@ -2473,127 +2839,7 @@ describe("POST /interactions", () => { expect(kvPut).not.toHaveBeenCalledWith("user_repo_branch:U123:acme/unknown", "release/2026-03"); }); - it("prefers repo branch over global branch when creating a session", async () => { - const slackFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - return new Response(JSON.stringify({ ok: true, channel: "C123", ts: "123.456" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }); - - const payload = { - type: "block_actions", - user: { id: "U123" }, - channel: { id: "C123" }, - message: { ts: "111.222" }, - actions: [ - { - action_id: "select_repo", - selected_option: { value: "acme/app" }, - }, - ], - }; - - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); - - const env = makeEnv(); - await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( - "pending:C123:111.222", - JSON.stringify({ - message: "Please handle this", - userId: "U123", - }) - ); - await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( - "user_preferences:U123", - JSON.stringify({ - userId: "U123", - model: "anthropic/claude-haiku-4-5", - reasoningEffort: "medium", - branch: "global-branch", - updatedAt: Date.now(), - }) - ); - await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( - "user_repo_branch:U123:acme/app", - "repo-branch" - ); - - env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/repos")) { - return new Response( - JSON.stringify( - mockReposResponseBody([ - { - id: "acme/app", - owner: "acme", - name: "app", - fullName: "acme/app", - defaultBranch: "main", - private: true, - }, - ]) - ), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - } - - if (url.endsWith("/sessions")) { - return new Response(JSON.stringify({ sessionId: "session-1", status: "created" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - if (url.includes("/prompt")) { - return new Response(JSON.stringify({ messageId: "msg-1" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - return new Response(JSON.stringify({ enabledModels: ["anthropic/claude-haiku-4-5"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }); - - const ctx = makeCtx(); - const response = await app.fetch(request, env, ctx); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true }); - - await flushWaitUntil(ctx); - await flushWaitUntil(ctx, 1); - expect(ctx.waitUntil).toHaveBeenCalledTimes(3); - - const sessionCall = env.CONTROL_PLANE.fetch.mock.calls.find(([input]) => { - const url = typeof input === "string" ? input : (input as URL).toString(); - return url.endsWith("/sessions"); - }); - - expect(sessionCall).toBeTruthy(); - const init = sessionCall?.[1] as RequestInit; - const body = JSON.parse(String(init.body)) as { branch?: string }; - expect(body.branch).toBe("repo-branch"); - - slackFetch.mockRestore(); - }); - - it("forwards display identity fields from getUserInfo to session creation", async () => { + it("forwards display identity and prefers repo branch over global branch on session creation", async () => { const slackFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { return new Response(JSON.stringify({ ok: true, channel: "C123", ts: "123.456" }), { status: 200, @@ -2627,15 +2873,7 @@ describe("POST /interactions", () => { ], }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( @@ -2645,9 +2883,21 @@ describe("POST /interactions", () => { userId: "U123", }) ); + await env.SLACK_KV.put( + "user_prefs:U123", + JSON.stringify({ + userId: "U123", + model: "anthropic/claude-haiku-4-5", + reasoningEffort: "medium", + branch: "global-branch", + updatedAt: Date.now(), + }) + ); + await env.SLACK_KV.put("user_repo_branch:U123:acme/app", "repo-branch"); env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) return Response.json({ teamId: null }); if (url.includes("/repos")) { return new Response( JSON.stringify( @@ -2698,6 +2948,7 @@ describe("POST /interactions", () => { const body = JSON.parse(String(init.body)) as Record; expect(body.actorDisplayName).toBe("Jane"); expect(body.actorEmail).toBe("jane@example.com"); + expect(body.branch).toBe("repo-branch"); expect(mockGetUserInfo).toHaveBeenCalledOnce(); // Identity travels via the signed actor assertion, never the body. expect(body.actorUserId).toBeUndefined(); @@ -2729,15 +2980,7 @@ describe("POST /interactions", () => { ], }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( @@ -2750,6 +2993,7 @@ describe("POST /interactions", () => { env.CONTROL_PLANE.fetch.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/channel-bindings/slack/")) return Response.json({ teamId: null }); if (url.includes("/repos")) { return new Response( JSON.stringify( @@ -2821,15 +3065,7 @@ describe("POST /interactions", () => { ], }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); await (env.SLACK_KV as unknown as { put: (k: string, v: string) => Promise }).put( @@ -2859,15 +3095,7 @@ describe("POST /interactions", () => { value: "repo-150", }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const repos = buildNumberedRepos(150); @@ -2909,15 +3137,7 @@ describe("POST /interactions", () => { }, ], }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const ctx = makeCtx(); const response = await app.fetch(request, env, ctx); @@ -2934,31 +3154,45 @@ describe("POST /interactions", () => { }); it("returns all repos (beyond the old 5-item limit) for the repo clarification picker", async () => { + const slackFetch = mockSlackFetch(); const payload = { type: "block_suggestion", action_id: "select_repo", + block_id: "target_picker:00000000-0000-4000-8000-000000000001", user: { id: "U123" }, + channel: { id: "C123" }, value: "", }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); + request.headers.delete("x-slack-signature"); + request.headers.delete("x-slack-request-timestamp"); const env = makeEnv(); const repos = buildNumberedRepos(150); mockReposFetch(env, repos); + await env.SLACK_KV.put( + "pending:00000000-0000-4000-8000-000000000001", + JSON.stringify({ + requestId: "00000000-0000-4000-8000-000000000001", + channel: "C123", + threadTs: "111.222", + message: "Fix it", + userId: "U123", + teamId: null, + }) + ); const ctx = makeCtx(); const response = await app.fetch(request, env, ctx); expect(response.status).toBe(200); + expect(mockVerifySlackSignature).toHaveBeenCalledWith( + null, + null, + new URLSearchParams({ payload: JSON.stringify(payload) }).toString(), + env.SLACK_SIGNING_SECRET + ); expect(ctx.waitUntil).not.toHaveBeenCalled(); const body = (await response.json()) as { @@ -2972,29 +3206,37 @@ describe("POST /interactions", () => { description: { type: "plain_text", text: "Start without cloning a repository" }, value: "__no_repository__", }); + expect(slackFetch).not.toHaveBeenCalled(); + slackFetch.mockRestore(); }); - it("filters repo clarification suggestions by the typed query", async () => { + it.each([null, "team-a"])("filters channel clarification suggestions (%s)", async (teamId) => { + const slackFetch = mockSlackFetch(); const payload = { type: "block_suggestion", action_id: "select_repo", + block_id: "target_picker:00000000-0000-4000-8000-000000000001", user: { id: "U123" }, + channel: { id: "C123" }, value: "repo-150", }; - const request = new Request("http://localhost/interactions", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "x-slack-signature": "v0=test", - "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, - }, - body: new URLSearchParams({ payload: JSON.stringify(payload) }), - }); + const request = slackInteractionRequest(payload); const env = makeEnv(); const repos = buildNumberedRepos(150); - mockReposFetch(env, repos); + mockReposFetch(env, repos, teamId); + await env.SLACK_KV.put( + "pending:00000000-0000-4000-8000-000000000001", + JSON.stringify({ + requestId: "00000000-0000-4000-8000-000000000001", + channel: "C123", + threadTs: "111.222", + message: "Fix it", + userId: "U123", + teamId, + }) + ); const ctx = makeCtx(); const response = await app.fetch(request, env, ctx); @@ -3016,5 +3258,90 @@ describe("POST /interactions", () => { value: "acme/repo-150", }, ]); + expect(env.SLACK_KV.get).toHaveBeenCalledWith( + "pending:00000000-0000-4000-8000-000000000001", + "json" + ); + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledWith( + "https://internal/channel-bindings/slack/C123", + expect.anything() + ); + for (const resource of ["repos", "environments"]) { + const catalogReads = env.CONTROL_PLANE.fetch.mock.calls.filter( + ([url]) => new URL(String(url)).pathname === `/${resource}` + ); + expect(catalogReads).toHaveLength(1); + for (const [url, init] of catalogReads) { + expect(String(url)).toBe(`https://internal/${resource}?channel=slack%3AC123`); + const headers = new Headers(init?.headers); + expect(headers.get("X-OpenInspect-Actor")).toBe("slack:U123"); + expect(headers.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + } + } + expect(slackFetch).not.toHaveBeenCalled(); + slackFetch.mockRestore(); + }); + + it.each<{ binding?: unknown; pending?: unknown; payload?: Record }>([ + { binding: { teamId: "team-b", kind: "primary" } }, + { binding: { teamId: null } }, + { binding: 404 }, + { binding: 503 }, + { binding: { invalid: true } }, + { binding: new Error("CP offline") }, + { pending: null }, + { pending: new Error("KV unavailable") }, + { pending: { teamId: undefined } }, + { payload: { block_id: undefined } }, + { payload: { block_id: "malformed" } }, + { payload: { user: { id: "other" } } }, + { payload: { user: undefined } }, + { payload: { channel: { id: "other" } } }, + { payload: { channel: undefined } }, + ])("withholds suggestions and visible instructions for untrusted scope: %j", async (failure) => { + const slackFetch = mockSlackFetch(); + const env = makeEnv(); + const requestId = "00000000-0000-4000-8000-000000000001"; + const pending = { + requestId, + channel: "C123", + threadTs: "111.222", + userId: "U123", + message: "Fix it", + teamId: "team-a", + }; + const kv = env.SLACK_KV as unknown as ReturnType; + kv.get.mockImplementation(async () => { + if (failure.pending instanceof Error) throw failure.pending; + return failure.pending === null ? null : { ...pending, ...(failure.pending as object) }; + }); + env.CONTROL_PLANE.fetch.mockImplementation(async () => { + if (failure.binding instanceof Error) throw failure.binding; + return typeof failure.binding === "number" + ? new Response(null, { status: failure.binding }) + : Response.json(failure.binding ?? { teamId: "team-a", kind: "primary" }); + }); + const ctx = makeCtx(); + const response = await app.fetch( + slackInteractionRequest({ + type: "block_suggestion", + action_id: "select_repo", + value: "app", + user: { id: "U123" }, + channel: { id: "C123" }, + block_id: `target_picker:${requestId}`, + ...failure.payload, + }), + env, + ctx + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ options: [] }); + expect(env.CONTROL_PLANE.fetch.mock.calls.map(([url]) => String(url))).toEqual( + "binding" in failure ? ["https://internal/channel-bindings/slack/C123"] : [] + ); + expect(slackFetch).not.toHaveBeenCalled(); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + slackFetch.mockRestore(); }); }); diff --git a/packages/slack-bot/src/interaction-payload.ts b/packages/slack-bot/src/interaction-payload.ts index 4ae0888d03..30f2eecb1b 100644 --- a/packages/slack-bot/src/interaction-payload.ts +++ b/packages/slack-bot/src/interaction-payload.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const slackInteractionPayloadSchema = z.object({ type: z.string(), action_id: z.string().optional(), + block_id: z.string().optional(), value: z.string().optional(), trigger_id: z.string().optional(), actions: z diff --git a/packages/slack-bot/src/interactions/target-selection.test.ts b/packages/slack-bot/src/interactions/target-selection.test.ts index b4ed94d943..c2eb2cfe46 100644 --- a/packages/slack-bot/src/interactions/target-selection.test.ts +++ b/packages/slack-bot/src/interactions/target-selection.test.ts @@ -20,6 +20,14 @@ import { import { resolveTargetValue } from "../target-clarification"; import { resolveSlackActorIdentity } from "../user-identity"; import { fetchInteractiveThreadContext } from "../interactive-thread-context"; +import { resolveChannelBinding } from "../channel-bindings"; +import { lookupThreadSession } from "../sessions/thread-session-store"; + +vi.mock("../channel-bindings", () => ({ resolveChannelBinding: vi.fn() })); +vi.mock("../sessions/thread-session-store", () => ({ + lookupThreadSession: vi.fn(), + THREAD_CLOSED_MESSAGE: "this session is no longer available from this channel", +})); vi.mock(import("@open-inspect/shared/slack"), async (importOriginal) => ({ ...(await importOriginal()), @@ -132,6 +140,8 @@ function selectionRequest(selectedValue = DEFAULT_SELECTED_VALUE) { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveChannelBinding).mockResolvedValue({ teamId: null }); + vi.mocked(lookupThreadSession).mockResolvedValue(null); vi.mocked(resolveTargetValue).mockResolvedValue(repositoryTarget); vi.mocked(resolveSlackActorIdentity).mockResolvedValue({ userId: "U123", @@ -142,6 +152,22 @@ beforeEach(() => { }); describe("handleTargetSelection", () => { + it("refuses selection after a binding changes or fails lookup", async () => { + vi.mocked(getPendingRequest).mockResolvedValue(pendingRequest({ teamId: "team-a" })); + const env = makeEnv(); + await handleTargetSelection(selectionRequest(), env, "trace", vi.fn()); + expect(resolveChannelBinding).toHaveBeenCalledWith(env, "C123", "111.222", "trace"); + expect(postMessage).toHaveBeenCalledWith( + "xoxb-test", + "C123", + expect.stringContaining("binding has changed"), + { thread_ts: "111.222" } + ); + vi.mocked(resolveChannelBinding).mockResolvedValue(null); + await handleTargetSelection(selectionRequest(), env, "trace", vi.fn()); + expect(resolveTargetValue).not.toHaveBeenCalled(); + expect(startSessionAndSendPrompt).not.toHaveBeenCalled(); + }); it("re-fetches files and forwards the resolved turn plan unchanged", async () => { vi.mocked(getPendingRequest).mockResolvedValue( pendingRequest({ @@ -198,14 +224,23 @@ describe("handleTargetSelection", () => { }); it("launches without images when the pending request has no source message", async () => { - vi.mocked(getPendingRequest).mockResolvedValue(pendingRequest()); + vi.mocked(getPendingRequest).mockResolvedValue(pendingRequest({ teamId: "team-a" })); + vi.mocked(resolveChannelBinding).mockResolvedValue({ teamId: "team-a", kind: "primary" }); + const env = makeEnv(); - await handleTargetSelection(selectionRequest(), makeEnv(), "trace-1", vi.fn()); + await handleTargetSelection(selectionRequest(), env, "trace-1", vi.fn()); expect(getMessageDetails).not.toHaveBeenCalled(); + expect(resolveTargetValue).toHaveBeenCalledWith( + env, + DEFAULT_SELECTED_VALUE, + "trace-1", + "C123", + "U123" + ); expect(startSessionAndSendPrompt).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ messageText: "Fix the deploy", images: [] }) + env, + expect.objectContaining({ messageText: "Fix the deploy", images: [], teamId: "team-a" }) ); }); @@ -429,14 +464,13 @@ describe("handleTargetSelection", () => { "Only the person who made the original request can choose its target.", { thread_ts: "111.222" } ); + expect(resolveChannelBinding).not.toHaveBeenCalled(); expect(postMessage).not.toHaveBeenCalled(); expect(updateMessage).not.toHaveBeenCalled(); }); - it("rejects a request whose stored channel or thread does not match the interaction", async () => { - vi.mocked(getPendingRequest).mockResolvedValue( - pendingRequest({ channel: "C999", threadTs: "999.000" }) - ); + it.each([{ channel: "C999" }, { threadTs: "999.000" }])("rejects mismatched %j", async (row) => { + vi.mocked(getPendingRequest).mockResolvedValue(pendingRequest(row)); await handleTargetSelection(selectionRequest(), makeEnv(), "trace-1", vi.fn()); @@ -449,6 +483,7 @@ describe("handleTargetSelection", () => { expect.stringContaining("no longer matches"), { thread_ts: "111.222" } ); + expect(resolveChannelBinding).not.toHaveBeenCalled(); }); it("launches the request bound to the clicked picker when another request shares its thread", async () => { @@ -471,6 +506,13 @@ describe("handleTargetSelection", () => { await handleTargetSelection(selectionRequest(), makeEnv(), "trace-1", vi.fn()); expect(getPendingRequest).toHaveBeenCalledWith(expect.anything(), REQUEST_ID); + expect(resolveTargetValue).toHaveBeenCalledWith( + expect.anything(), + DEFAULT_SELECTED_VALUE, + "trace-1", + "C123", + "U123" + ); expect(startSessionAndSendPrompt).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ messageText: "Alice's original request" }) diff --git a/packages/slack-bot/src/interactions/target-selection.ts b/packages/slack-bot/src/interactions/target-selection.ts index 8b224f24ae..8137ca8044 100644 --- a/packages/slack-bot/src/interactions/target-selection.ts +++ b/packages/slack-bot/src/interactions/target-selection.ts @@ -9,6 +9,8 @@ import { MODEL_PREFERENCES_UNAVAILABLE_MESSAGE } from "../app-home/models"; import { collectForwardedMessages } from "../forwarded-messages"; import { fetchInteractiveThreadContext } from "../interactive-thread-context"; import { createLogger } from "../logger"; +import { resolveChannelBinding } from "../channel-bindings"; +import { lookupThreadSession, THREAD_CLOSED_MESSAGE } from "../sessions/thread-session-store"; import { buildWorkingMessage, formatSessionDefaultsNotice, @@ -139,6 +141,24 @@ export async function handleTargetSelection( ); return; } + if ((await lookupThreadSession(env, channel, threadKey))?.closed) { + await postMessage(env.SLACK_BOT_TOKEN, channel, THREAD_CLOSED_MESSAGE, { + thread_ts: threadKey, + }); + return; + } + const binding = await resolveChannelBinding(env, channel, threadKey, traceId); + if (!binding) return; + if (pendingData.teamId !== undefined && pendingData.teamId !== binding.teamId) { + await postMessage( + env.SLACK_BOT_TOKEN, + channel, + "This channel's binding has changed. Please start a new request.", + { thread_ts: threadKey } + ); + return; + } + const teamId = pendingData.teamId === undefined ? binding.teamId : pendingData.teamId; const legacyInlinePromptOptions = !requestId && "inlinePromptOptions" in pendingData ? pendingData.inlinePromptOptions @@ -178,7 +198,7 @@ export async function handleTargetSelection( } resolvedLaunchPlan = { sessionDefaults: resolvedTurn.turnPlan.effective }; } - const target = await resolveTargetValue(env, selectedValue, traceId); + const target = await resolveTargetValue(env, selectedValue, traceId, channel, userId); if (!target) { await postMessage( env.SLACK_BOT_TOKEN, @@ -264,6 +284,7 @@ export async function handleTargetSelection( : message; const sessionResult = await startSessionAndSendPrompt(env, { target, + teamId, channel, threadTs: threadKey, messageText, diff --git a/packages/slack-bot/src/pending-requests/pending-request-store.test.ts b/packages/slack-bot/src/pending-requests/pending-request-store.test.ts index 3593e74ba9..4f8cf95c29 100644 --- a/packages/slack-bot/src/pending-requests/pending-request-store.test.ts +++ b/packages/slack-bot/src/pending-requests/pending-request-store.test.ts @@ -53,8 +53,9 @@ describe("pending request store", () => { mocks = makeEnv(); }); - it("stores requests under the request id for one hour", async () => { + it.each(["team-a", null])("stores scope %s by request id for one hour", async (teamId) => { const pending = request({ + teamId, unattributedPrompt: { forwardedMessages: ["Forwarded body"] }, previousMessages: ["Earlier context"], channelName: "engineering", @@ -70,6 +71,8 @@ describe("pending request store", () => { expirationTtl: 3600, }); expect(JSON.parse(mocks.put.mock.calls[0][1])).toEqual(pending); + mocks.get.mockResolvedValue(JSON.parse(mocks.put.mock.calls[0][1])); + expect(await getPendingRequest(mocks.env, REQUEST_ID)).toEqual(pending); }); it("keeps requests in the same thread distinct", async () => { @@ -116,27 +119,19 @@ describe("pending request store", () => { it.each([ {}, - [], - { message: "Fix it" }, - { userId: "U123" }, - { message: 123, userId: "U123" }, - { message: "Fix it", userId: "" }, - { message: "Fix it", userId: "U123", previousMessages: ["valid", 123] }, - { message: "Fix it", userId: "U123", unattributedPrompt: {} }, - { message: "Fix it", userId: "U123", unattributedPrompt: { forwardedMessages: [123] } }, - { message: "Fix it", userId: "U123", channelName: 123 }, - { message: "Fix it", userId: "U123", turnPlan: {} }, + { ...request(), requestId: "not-a-uuid" }, + { ...request(), channel: "" }, + { ...request(), threadTs: "" }, + { ...request(), userId: "" }, + { ...request(), teamId: "" }, + { ...request(), previousMessages: ["valid", 123] }, { - message: "Fix it", - userId: "U123", + ...request(), turnPlan: { ...TURN_PLAN, effective: { model: "openai/gpt-5.4", reasoningEffort: "high" }, }, }, - { ...request(), requestId: "not-a-uuid" }, - { ...request(), channel: "" }, - { ...request(), threadTs: "" }, ])("rejects malformed records: %j", async (record) => { mocks.get.mockResolvedValue(record); diff --git a/packages/slack-bot/src/pending-requests/pending-request-store.ts b/packages/slack-bot/src/pending-requests/pending-request-store.ts index cb7f1c7a33..ade086fc26 100644 --- a/packages/slack-bot/src/pending-requests/pending-request-store.ts +++ b/packages/slack-bot/src/pending-requests/pending-request-store.ts @@ -38,6 +38,7 @@ const classificationSchema = z.object({ const pendingRequestDataSchema = z.object({ message: z.string().min(1), userId: z.string().min(1), + teamId: z.string().min(1).nullable().optional(), /** Present when `message` still needs sender attribution before delivery. */ unattributedPrompt: unattributedPromptSchema.optional(), previousMessages: z.array(z.string()).optional(), diff --git a/packages/slack-bot/src/routes/channel-info.test.ts b/packages/slack-bot/src/routes/channel-info.test.ts new file mode 100644 index 0000000000..34a581b6b0 --- /dev/null +++ b/packages/slack-bot/src/routes/channel-info.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { computeHmacHex } from "@open-inspect/shared/auth"; +import app from "../app"; +import type { Env } from "../types"; +import { makeExecutionContext } from "../test-helpers"; + +const env = { + SERVICE_AUTH_SECRET: "callback-secret", + SLACK_SIGNING_SECRET: "slack-secret", + SLACK_BOT_TOKEN: "own-bot-token", +} as Env; +async function request( + data: { channelId: string; timestamp: number }, + secret = "callback-secret", + bindings = env +) { + return app.fetch( + new Request("https://bot/internal/channel-info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...data, + signature: await computeHmacHex(JSON.stringify(data), secret), + }), + }), + bindings, + makeExecutionContext() + ); +} + +describe("POST /internal/channel-info", () => { + afterEach(() => vi.restoreAllMocks()); + + it.each([ + { is_member: true, is_ext_shared: false }, + { is_member: false, is_ext_shared: false }, + { is_member: true, is_ext_shared: true }, + ])("uses its own Slack token and faithfully returns validation flags: %j", async (flags) => { + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ + ok: true, + channel: { id: "C123", name: "eng", ...flags }, + }) + ); + const response = await request({ channelId: "C123", timestamp: Date.now() }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + id: "C123", + name: "eng", + isMember: flags.is_member, + isExtShared: flags.is_ext_shared, + }); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("conversations.info"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer own-bot-token" }), + }) + ); + }); + + it.each([-10 * 60 * 1000, 10 * 60 * 1000])( + "rejects timestamps outside the window: %s", + async (offset) => { + const fetch = vi.spyOn(globalThis, "fetch"); + expect((await request({ channelId: "C123", timestamp: Date.now() + offset })).status).toBe( + 401 + ); + expect(fetch).not.toHaveBeenCalled(); + } + ); + + it("does not accept the Slack webhook signing secret as callback authentication", async () => { + const fetch = vi.spyOn(globalThis, "fetch"); + const response = await request( + { channelId: "C123", timestamp: Date.now() }, + env.SLACK_SIGNING_SECRET + ); + expect(response.status).toBe(401); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("requires SERVICE_AUTH_SECRET even when the Slack webhook signing secret is configured", async () => { + const fetch = vi.spyOn(globalThis, "fetch"); + const response = await request( + { channelId: "C123", timestamp: Date.now() }, + "callback-secret", + { + ...env, + SERVICE_AUTH_SECRET: "", + } + ); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "not configured" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("fails closed when Slack cannot resolve the channel", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ ok: false, error: "channel_not_found" }) + ); + expect((await request({ channelId: "C123", timestamp: Date.now() })).status).toBe(404); + }); + + it.each(["is_member", "is_ext_shared"])( + "does not infer safety when Slack omits %s", + async (flag) => { + const channel: Record = { + id: "C123", + name: "eng", + is_member: true, + is_ext_shared: false, + }; + delete channel[flag]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true, channel })); + expect((await request({ channelId: "C123", timestamp: Date.now() })).status).toBe(502); + } + ); +}); diff --git a/packages/slack-bot/src/routes/channel-info.ts b/packages/slack-bot/src/routes/channel-info.ts new file mode 100644 index 0000000000..bf8ef17c26 --- /dev/null +++ b/packages/slack-bot/src/routes/channel-info.ts @@ -0,0 +1,47 @@ +import { TOKEN_VALIDITY_MS, verifyCallbackFromControlPlane } from "@open-inspect/shared/auth"; +import { getChannelInfo } from "@open-inspect/shared/slack"; +import { Hono } from "hono"; +import { z } from "zod"; +import type { Env } from "../types"; + +const channelInfoRequestSchema = z.object({ + channelId: z.string().min(1), + timestamp: z.number().int().nonnegative(), + signature: z.string().min(1), +}); + +export const channelInfoRoutes = new Hono<{ Bindings: Env }>(); + +channelInfoRoutes.post("/internal/channel-info", async (c) => { + const parsed = channelInfoRequestSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "invalid payload" }, 400); + if (!c.env.SERVICE_AUTH_SECRET) return c.json({ error: "not configured" }, 500); + if (!(await verifyCallbackFromControlPlane(parsed.data, c.env))) { + return c.json({ error: "unauthorized" }, 401); + } + const { channelId, timestamp } = parsed.data; + if (Math.abs(Date.now() - timestamp) > TOKEN_VALIDITY_MS) { + return c.json({ error: "unauthorized" }, 401); + } + try { + const result = await getChannelInfo(c.env.SLACK_BOT_TOKEN, channelId); + if (!result.ok) { + return c.json( + { error: "channel unavailable" }, + result.error === "channel_not_found" ? 404 : 502 + ); + } + const channel = result.channel; + if (typeof channel.is_member !== "boolean" || typeof channel.is_ext_shared !== "boolean") { + return c.json({ error: "channel unavailable" }, 502); + } + return c.json({ + id: channel.id, + name: channel.name, + isMember: channel.is_member, + isExtShared: channel.is_ext_shared, + }); + } catch { + return c.json({ error: "channel unavailable" }, 502); + } +}); diff --git a/packages/slack-bot/src/routes/interactions.ts b/packages/slack-bot/src/routes/interactions.ts index e1e82bb93d..2bc956b04d 100644 --- a/packages/slack-bot/src/routes/interactions.ts +++ b/packages/slack-bot/src/routes/interactions.ts @@ -4,11 +4,15 @@ import { handleAppHomeInteractionRoute } from "../app-home"; import { handleSlackInteraction } from "../interactions/dispatcher"; import { slackInteractionPayloadSchema } from "../interaction-payload"; import { createLogger } from "../logger"; +import { getChannelBinding } from "../channel-bindings"; import { SELECT_TARGET_ACTION_ID, countClarificationOptions, getTargetClarificationOptions, + parseTargetInteractionRequestId, + type TargetClarificationOptions, } from "../target-clarification"; +import { getPendingRequest } from "../pending-requests/pending-request-store"; import type { Env } from "../types"; const log = createLogger("handler"); @@ -66,20 +70,41 @@ interactionRoutes.post("/interactions", async (c) => { return c.json(appHomeResponse.body); } if (payload.type === "block_suggestion") { - const response = - payload.action_id === SELECT_TARGET_ACTION_ID - ? await getTargetClarificationOptions(c.env, payload.value, traceId).catch( - (e): { options: [] } => { - log.error("slack.target_clarification_options", { - trace_id: traceId, - query: payload.value, - error: e instanceof Error ? e : new Error(String(e)), - duration_ms: Date.now() - startTime, - }); - return { options: [] }; - } - ) - : { options: [] }; + const requestId = payload.block_id + ? parseTargetInteractionRequestId(payload.block_id, "picker") + : null; + const pending = + payload.action_id === SELECT_TARGET_ACTION_ID && requestId + ? await getPendingRequest(c.env, requestId).catch(() => null) + : null; + let response: TargetClarificationOptions = { options: [] }; + if ( + pending && + payload.user && + pending.userId === payload.user.id && + pending.teamId !== undefined && + payload.channel?.id === pending.channel + ) { + try { + const binding = await getChannelBinding(c.env, pending.channel, traceId); + if (binding.teamId === pending.teamId) { + response = await getTargetClarificationOptions( + c.env, + payload.value, + traceId, + pending.channel, + payload.user.id + ); + } + } catch (error) { + log.error("slack.target_clarification_options", { + trace_id: traceId, + query: payload.value, + error: error instanceof Error ? error : new Error(String(error)), + duration_ms: Date.now() - startTime, + }); + } + } log.info("http.request", { trace_id: traceId, http_method: "POST", diff --git a/packages/slack-bot/src/sessions/control-plane-client.test.ts b/packages/slack-bot/src/sessions/control-plane-client.test.ts index 34d076a344..eeaad8af4e 100644 --- a/packages/slack-bot/src/sessions/control-plane-client.test.ts +++ b/packages/slack-bot/src/sessions/control-plane-client.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Environment } from "@open-inspect/shared/types/environments"; import type { ControlPlaneEnv } from "../internal-auth"; -import { createSession, sendPrompt } from "./control-plane-client"; +import { checkPublicationAccess, createSession, sendPrompt } from "./control-plane-client"; import { OUTBOUND_REQUEST_TIMEOUT_MS } from "../request-options"; +import { sha256Hex, verifyServiceSignature } from "@open-inspect/shared/service-auth"; function makeEnv(fetch: ControlPlaneEnv["CONTROL_PLANE"]["fetch"]): ControlPlaneEnv { return { @@ -85,6 +86,7 @@ describe("control plane client timeouts", () => { }); const result = sendPrompt(makeEnv(fetch), { sessionId: "session-1", + channel: "C123", content: "Fix it", authorId: "slack:U123", }); @@ -97,32 +99,102 @@ describe("control plane client timeouts", () => { expect(fetch.mock.calls[0]?.[1]?.signal).toBe(controller.signal); }); - it("classifies only not-found prompt responses as stale", async () => { - const notFoundFetch = vi.fn(async () => new Response(null, { status: 404 })); - const serverErrorFetch = vi.fn(async () => new Response(null, { status: 503 })); - - await expect( - sendPrompt(makeEnv(notFoundFetch), { - sessionId: "missing-session", - content: "Fix it", - authorId: "slack:U123", - }) - ).resolves.toEqual({ ok: false, reason: "stale" }); + it.each([ + [403, "forbidden"], + [404, "stale"], + [503, "transient"], + ] as const)("classifies prompt status %s as %s without retrying", async (status, reason) => { + const fetch = vi.fn(async () => new Response(null, { status })); await expect( - sendPrompt(makeEnv(serverErrorFetch), { + sendPrompt(makeEnv(fetch), { sessionId: "session-1", + channel: "C123", content: "Fix it", authorId: "slack:U123", }) - ).resolves.toEqual({ ok: false, reason: "transient" }); + ).resolves.toEqual({ ok: false, reason }); + expect(fetch).toHaveBeenCalledOnce(); }); }); +describe("prompt channel scope", () => { + it.each([400, 403, 404, 503])( + "distinguishes a channel-wide scope refusal at %s", + async (status) => { + const fetch = vi.fn(async () => okJson({ code: "slack_channel_scope_denied" }, status)); + expect( + await sendPrompt(makeEnv(fetch), { + sessionId: "session-1", + channel: "C123", + content: "Do not forward", + authorId: "slack:U123", + }) + ).toEqual({ ok: false, reason: "channel_scope_denied" }); + } + ); +}); + +describe("publication access", () => { + it.each([ + [200, { artifacts: [] }, "allowed"], + [403, { error: "Forbidden" }, "denied"], + [404, { error: "Session not found" }, "denied"], + [503, {}, "unavailable"], + [200, { invalid: true }, "unavailable"], + ["invalid-json", null, "unavailable"], + ["network", null, "unavailable"], + ] as const)( + "classifies a protected status %s without inferring access from cached metadata", + async (status, body, result) => { + const fetch = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => { + if (status === "network") throw new Error("offline"); + return status === "invalid-json" ? new Response("{") : okJson(body, status); + }); + expect(await checkPublicationAccess(makeEnv(fetch), "s1", "C1", "trace")).toBe(result); + if (result !== "allowed") return; + const [url, request] = fetch.mock.calls[0]; + expect(new URL(String(url)).pathname).toBe("/sessions/s1/artifacts"); + expect(new URL(String(url)).searchParams.get("channel")).toBe("slack:C1"); + expect(new URL(String(url)).searchParams.get("purpose")).toBe("slack-post"); + expect(request?.method).toBe("GET"); + const headers = new Headers(request?.headers); + expect(headers.get("X-OpenInspect-Service")).toBe("slack-bot"); + expect(headers.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + expect(headers.get("X-OpenInspect-Actor")).toBeNull(); + expect(headers.get("x-trace-id")).toBe("trace"); + } + ); +}); + describe("control plane client request payloads", () => { afterEach(() => { vi.restoreAllMocks(); }); + it.each(["team-a", null])("sends explicit team scope %s on launch", async (teamId) => { + const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + okJson({ sessionId: "s1", status: "created" }) + ); + await createSession(makeEnv(fetch), { target, model: "openai/gpt-5.4", teamId }); + expect(parseRequestBody(fetch)).toMatchObject({ teamId }); + }); + + it.each([ + [403, { code: "session_action_denied", reason_code: "not_member" }], + [403, { code: "not_member" }], + [409, { code: "target_team_missing_grant", repository: "acme/app" }], + ] as const)("preserves create refusal details at %s", async (status, body) => { + const fetch = vi.fn(async () => okJson(body, status)); + expect(await createSession(makeEnv(fetch), { target, model: "openai/gpt-5.4" })).toEqual({ + error: { + status, + code: body.code, + reasonCode: "reason_code" in body ? body.reason_code : undefined, + repository: "repository" in body ? body.repository : undefined, + }, + }); + }); + it("creates repository sessions with target, model, and branch — identity stays out of the body", async () => { const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => okJson({ sessionId: "session-1", status: "created" }) @@ -200,6 +272,7 @@ describe("control plane client request payloads", () => { await sendPrompt(makeEnv(fetch), { sessionId: "session-1", + channel: "C123", content: "Use the screenshot", authorId: "slack:U123", model: "openai/gpt-5.6-sol", @@ -208,6 +281,7 @@ describe("control plane client request payloads", () => { }); await sendPrompt(makeEnv(fetch), { sessionId: "session-1", + channel: "C123", content: "No attachments", authorId: "slack:U123", attachments: [], @@ -256,10 +330,11 @@ describe("service credential headers", () => { expect(headers["Authorization"]).toBeUndefined(); }); - it("signs prompts with the author as the asserted actor", async () => { + it("signs prompts with the author and channel coordinate", async () => { const fetch = vi.fn(async () => new Response(JSON.stringify({ messageId: "m1" }))); await sendPrompt(makeServiceEnv(fetch), { sessionId: "session-1", + channel: "C123", content: "Fix it", authorId: "slack:U456", }); @@ -267,6 +342,23 @@ describe("service credential headers", () => { const headers = sentHeaders(fetch); expect(headers["X-OpenInspect-Service-Signature"]).toMatch(/^sig1\./); expect(headers["X-OpenInspect-Actor"]).toBe("slack:U456"); + const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]; + expect(new URL(url).searchParams.get("channel")).toBe("slack:C123"); + const signed = { + signatureHeader: headers["X-OpenInspect-Service-Signature"], + service: "slack-bot" as const, + secret: "slack-service-secret", + method: "POST", + url, + bodySha256Hex: await sha256Hex(String(init.body)), + actor: "slack:U456", + }; + expect(await verifyServiceSignature(signed)).toMatchObject({ ok: true }); + const changed = new URL(url); + changed.searchParams.set("channel", "slack:C_OTHER"); + expect(await verifyServiceSignature({ ...signed, url: changed.toString() })).toMatchObject({ + ok: false, + }); }); it("sends no request at all when SERVICE_AUTH_SECRET is unset", async () => { @@ -277,6 +369,7 @@ describe("service credential headers", () => { const result = await sendPrompt(env, { sessionId: "session-1", + channel: "C123", content: "Fix it", authorId: "slack:U456", }); diff --git a/packages/slack-bot/src/sessions/control-plane-client.ts b/packages/slack-bot/src/sessions/control-plane-client.ts index e7745fa3b0..d35eef5bfc 100644 --- a/packages/slack-bot/src/sessions/control-plane-client.ts +++ b/packages/slack-bot/src/sessions/control-plane-client.ts @@ -5,6 +5,7 @@ import { type SendPromptResponse, } from "@open-inspect/shared/types/session-api"; import type { SessionAttachmentReference } from "@open-inspect/shared/types/session-attachments"; +import { listArtifactsResponseSchema } from "@open-inspect/shared/types/artifacts"; import { signedControlPlaneFetch, type ControlPlaneEnv } from "../internal-auth"; import { createLogger } from "../logger"; import { buildSessionTargetRequestFields, targetId, type SlackSessionTarget } from "../targets"; @@ -15,6 +16,7 @@ const log = createLogger("handler"); interface CreateSessionOptions { target: SlackSessionTarget; + teamId?: string | null; model: string; reasoningEffort?: string; branch?: string; @@ -26,14 +28,44 @@ interface CreateSessionOptions { export type SendPromptResult = | { ok: true; data: SendPromptResponse } - | { ok: false; reason: "stale" | "transient" }; + | { ok: false; reason: "stale" | "forbidden" | "transient" | "channel_scope_denied" }; + +export interface CreateSessionFailure { + error: { status: number; code?: string; reasonCode?: string; repository?: string }; +} + +export async function checkPublicationAccess( + env: ControlPlaneEnv, + sessionId: string, + channel: string, + traceId?: string +): Promise<"allowed" | "denied" | "unavailable"> { + const url = new URL(`https://internal/sessions/${encodeURIComponent(sessionId)}/artifacts`); + url.searchParams.set("channel", `slack:${channel}`); + url.searchParams.set("purpose", "slack-post"); + try { + const response = await signedControlPlaneFetch( + env, + { method: "GET", url: url.toString(), traceId }, + { signal: AbortSignal.timeout(OUTBOUND_REQUEST_TIMEOUT_MS) } + ); + if (response.status === 403 || response.status === 404) return "denied"; + if (!response.ok) return "unavailable"; + return listArtifactsResponseSchema.safeParse(await response.json()).success + ? "allowed" + : "unavailable"; + } catch { + return "unavailable"; + } +} export async function createSession( env: ControlPlaneEnv, options: CreateSessionOptions -): Promise { +): Promise { const { target, + teamId, model, reasoningEffort, branch, @@ -55,6 +87,7 @@ export async function createSession( const url = "https://internal/sessions"; const body = JSON.stringify({ ...buildSessionTargetRequestFields(target, branch), + teamId, model, reasoningEffort, actorDisplayName, @@ -78,7 +111,17 @@ export async function createSession( http_status: response.status, duration_ms: Date.now() - startTime, }); - return null; + const details: unknown = await response.json().catch(() => null); + const body = + details && typeof details === "object" ? (details as Record) : {}; + return { + error: { + status: response.status, + code: typeof body.code === "string" ? body.code : undefined, + reasonCode: typeof body.reason_code === "string" ? body.reason_code : undefined, + repository: typeof body.repository === "string" ? body.repository : undefined, + }, + }; } const result = createSessionResponseSchema.safeParse(await response.json()); if (!result.success) { @@ -111,6 +154,7 @@ export async function createSession( export interface SendPromptOptions { sessionId: string; + channel: string; content: string; authorId: string; model?: string; @@ -126,6 +170,7 @@ export async function sendPrompt( ): Promise { const { sessionId, + channel, content, authorId, model, @@ -137,7 +182,8 @@ export async function sendPrompt( const startTime = Date.now(); const base = { trace_id: traceId, session_id: sessionId, source: "slack" }; try { - const url = `https://internal/sessions/${sessionId}/prompt`; + const url = new URL(`https://internal/sessions/${sessionId}/prompt`); + url.searchParams.set("channel", `slack:${channel}`); const body = JSON.stringify({ content, source: "slack", @@ -150,7 +196,7 @@ export async function sendPrompt( env, { method: "POST", - url, + url: url.toString(), body, actor: authorId.startsWith("slack:") ? authorId : undefined, traceId, @@ -164,7 +210,21 @@ export async function sendPrompt( http_status: response.status, duration_ms: Date.now() - startTime, }); - return { ok: false, reason: response.status === 404 ? "stale" : "transient" }; + const details = await response.json().catch(() => null); + return { + ok: false, + reason: + details !== null && + typeof details === "object" && + "code" in details && + details.code === "slack_channel_scope_denied" + ? "channel_scope_denied" + : response.status === 404 + ? "stale" + : response.status === 403 + ? "forbidden" + : "transient", + }; } const result = sendPromptResponseSchema.safeParse(await response.json()); if (!result.success) { diff --git a/packages/slack-bot/src/sessions/prompt-delivery.test.ts b/packages/slack-bot/src/sessions/prompt-delivery.test.ts index 2b0c2316b4..ac7563b938 100644 --- a/packages/slack-bot/src/sessions/prompt-delivery.test.ts +++ b/packages/slack-bot/src/sessions/prompt-delivery.test.ts @@ -58,6 +58,7 @@ describe("deliverPrompt", () => { expect(result).toEqual({ ok: true, data: { messageId: "message-1" } }); expect(sendPrompt).toHaveBeenCalledWith(env, { sessionId: "session-1", + channel: "C123", content: "Fix it", authorId: "slack:U123", callbackContext: undefined, @@ -76,19 +77,22 @@ describe("deliverPrompt", () => { expect(sendOrder).toBeLessThan(notifyOrder); }); - it("does not notify drops when the prompt send fails", async () => { - vi.mocked(uploadPreparedAttachments).mockResolvedValue({ - references: [], - dropped: ["download_failed"], - sessionMissing: false, - }); - vi.mocked(sendPrompt).mockResolvedValue({ ok: false, reason: "stale" }); + it.each(["stale", "forbidden", "channel_scope_denied"] as const)( + "propagates %s send failure", + async (reason) => { + vi.mocked(uploadPreparedAttachments).mockResolvedValue({ + references: [], + dropped: ["download_failed"], + sessionMissing: false, + }); + vi.mocked(sendPrompt).mockResolvedValue({ ok: false, reason }); - const result = await deliverPrompt(env, options()); + const result = await deliverPrompt(env, options()); - expect(result).toEqual({ ok: false, reason: "stale" }); - expect(notifyDroppedAttachments).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ ok: false, reason }); + expect(notifyDroppedAttachments).not.toHaveBeenCalled(); + } + ); it("sends no prompt for an image-only request that lost every image", async () => { vi.mocked(uploadPreparedAttachments).mockResolvedValue({ @@ -110,30 +114,27 @@ describe("deliverPrompt", () => { ); }); - it("surfaces staleness instead of a drop notice when the session is gone", async () => { + it.each([ + ["stale", { sessionMissing: true }], + ["forbidden", { sessionMissing: false, sessionForbidden: true }], + ["channel_scope_denied", { sessionMissing: false, channelScopeDenied: true }], + ] as const)("propagates %s upload refusal", async (reason, refusal) => { vi.mocked(uploadPreparedAttachments).mockResolvedValue({ - references: [], + references: + reason === "channel_scope_denied" + ? [{ attachmentId: "att-1", name: "accepted-before-rebind.png" }] + : [], dropped: ["upload_rejected"], - sessionMissing: true, + ...refusal, }); - const result = await deliverPrompt(env, options({ imageOnly: true })); + const result = await deliverPrompt( + env, + options({ imageOnly: reason !== "channel_scope_denied" }) + ); - expect(result).toEqual({ ok: false, reason: "stale" }); + expect(result).toEqual({ ok: false, reason }); expect(sendPrompt).not.toHaveBeenCalled(); expect(notifyDroppedAttachments).not.toHaveBeenCalled(); }); - - it("still sends a text prompt when images dropped but user text exists", async () => { - vi.mocked(uploadPreparedAttachments).mockResolvedValue({ - references: [], - dropped: ["download_failed"], - sessionMissing: false, - }); - - const result = await deliverPrompt(env, options({ imageOnly: false })); - - expect(result.ok).toBe(true); - expect(sendPrompt).toHaveBeenCalled(); - }); }); diff --git a/packages/slack-bot/src/sessions/prompt-delivery.ts b/packages/slack-bot/src/sessions/prompt-delivery.ts index 511442c55e..b86afda32b 100644 --- a/packages/slack-bot/src/sessions/prompt-delivery.ts +++ b/packages/slack-bot/src/sessions/prompt-delivery.ts @@ -39,12 +39,17 @@ export interface DeliverPromptOptions { export type DeliverPromptResult = | { ok: true; data: SendPromptResponse } /** - * "stale": the session no longer exists (retry against a new session). + * "stale": close the thread mapping; never start a replacement. + * "forbidden": refuse this user's prompt without closing the thread for others. + * "channel_scope_denied": close the thread for everyone; its channel authority no longer matches. * "transient": the prompt send failed; the user should be told to retry. * "no_images_delivered": an image-only request lost every image, so no * prompt was sent — the user has already been notified. */ - | { ok: false; reason: "stale" | "transient" | "no_images_delivered" }; + | { + ok: false; + reason: "stale" | "forbidden" | "transient" | "no_images_delivered" | "channel_scope_denied"; + }; /** Deliver one prompt and its image attachments to a session. */ export async function deliverPrompt( @@ -64,12 +69,19 @@ export async function deliverPrompt( threadTs, traceId, } = options; - const upload = await uploadPreparedAttachments(env, sessionId, attachments, authorId, traceId); + const upload = await uploadPreparedAttachments( + env, + sessionId, + attachments, + authorId, + channel, + traceId + ); + if (upload.channelScopeDenied) return { ok: false, reason: "channel_scope_denied" }; + if (upload.sessionForbidden) return { ok: false, reason: "forbidden" }; if (imageOnly && upload.references.length === 0) { - // The placeholder prompt would launch a meaningless run with nothing - // attached. When the uploads failed only because the session is gone, - // surface staleness instead so the caller retries on a fresh session. + // The placeholder needs an image. A missing session instead closes its thread. if (upload.sessionMissing) return { ok: false, reason: "stale" }; await notifyDroppedAttachments(env, channel, threadTs, upload, { traceId, @@ -80,6 +92,7 @@ export async function deliverPrompt( const promptResult = await sendPrompt(env, { sessionId, + channel, content, authorId, model, @@ -89,9 +102,7 @@ export async function deliverPrompt( traceId, }); if (!promptResult.ok) return promptResult; - // Notify about dropped images only now that the session proved live — - // uploads against a stale session fail spuriously and are retried against - // the replacement session. + // Notify about dropped images only now that the session proved accessible. await notifyDroppedAttachments(env, channel, threadTs, upload, { traceId }); return promptResult; } diff --git a/packages/slack-bot/src/sessions/session-launcher.test.ts b/packages/slack-bot/src/sessions/session-launcher.test.ts index a81a7f6722..054fce08ed 100644 --- a/packages/slack-bot/src/sessions/session-launcher.test.ts +++ b/packages/slack-bot/src/sessions/session-launcher.test.ts @@ -19,6 +19,7 @@ import { vi.mock("@open-inspect/shared/slack", () => ({ postMessage: vi.fn(), + escapeMrkdwnText: (text: string) => text, })); vi.mock("../attachments", () => ({ @@ -138,6 +139,35 @@ describe("startSessionAndSendPrompt", () => { vi.mocked(postMessage).mockResolvedValue({ ok: true, channel: "C123", ts: "111.333" }); }); + it.each([ + [ + { status: 403, code: "session_action_denied", reasonCode: "not_member" }, + "you are not a member of this channel's team", + ], + [{ status: 403, code: "not_member" }, "you are not a member of this channel's team"], + [ + { status: 409, code: "target_team_missing_grant", repository: "acme/private" }, + "This channel's team does not have access to repository acme/private.", + ], + ] as const)("reports a create refusal without sending a prompt: %s", async (error, message) => { + vi.mocked(createSession).mockResolvedValue({ error }); + const env = makeEnv(); + expect( + await startSessionAndSendPrompt(env, { + target: repositoryTarget, + channel: "C123", + threadTs: "111.222", + messageText: "Fix it", + actor, + teamId: "team-a", + }) + ).toBeNull(); + expect(postMessage).toHaveBeenCalledWith("xoxb-test", "C123", message, { + thread_ts: "111.222", + }); + expect(deliverPrompt).not.toHaveBeenCalled(); + }); + it("creates a repository session with resolved preferences and sends contextualized prompt", async () => { const env = makeEnv(); @@ -147,6 +177,7 @@ describe("startSessionAndSendPrompt", () => { channel: "C123", threadTs: "111.222", messageText: "Fix the failing deploy", + teamId: "team-a", actor, previousMessages: ["[Alice]: Earlier request", "[Bot]: Earlier response"], channelName: "engineering", @@ -162,6 +193,7 @@ describe("startSessionAndSendPrompt", () => { expect(getUserRepoBranchPreference).toHaveBeenCalledWith(env, "U123", "acme/app"); expect(createSession).toHaveBeenCalledWith(env, { target: repositoryTarget, + teamId: "team-a", model: "openai/gpt-5.4", reasoningEffort: "high", branch: "repo-override-branch", @@ -196,7 +228,8 @@ describe("startSessionAndSendPrompt", () => { repositoryTarget, "openai/gpt-5.4", "high", - undefined + undefined, + "team-a" ); expect(storeThreadSession).toHaveBeenCalledWith(env, "C123", "111.222", { sessionId: "session-1", @@ -247,6 +280,7 @@ describe("startSessionAndSendPrompt", () => { repositoryTarget, "anthropic/claude-sonnet-4-6", "max", + undefined, undefined ); }); @@ -254,8 +288,7 @@ describe("startSessionAndSendPrompt", () => { it("keeps a launch plan's prompt overrides off the session's stored defaults", async () => { const env = makeEnv(); - // How a stale-thread recovery launches: the replacement inherits the - // thread's defaults while the follow-up's own flags stay one-turn. + // Prompt overrides must not become the session's stored defaults. await startSessionAndSendPrompt(env, { target: repositoryTarget, channel: "C123", @@ -290,6 +323,7 @@ describe("startSessionAndSendPrompt", () => { repositoryTarget, "anthropic/claude-sonnet-4-6", "max", + undefined, undefined ); }); @@ -431,13 +465,14 @@ describe("startSessionAndSendPrompt", () => { channel: "C123", threadTs: "111.222", messageText: "Research this without cloning a repository", + teamId: null, actor, }); expect(getUserRepoBranchPreference).not.toHaveBeenCalled(); expect(createSession).toHaveBeenCalledWith( env, - expect.objectContaining({ target: noRepositoryTarget, branch: undefined }) + expect.objectContaining({ target: noRepositoryTarget, branch: undefined, teamId: null }) ); expect(deliverPrompt).toHaveBeenCalledWith( env, @@ -450,55 +485,9 @@ describe("startSessionAndSendPrompt", () => { noRepositoryTarget, "openai/gpt-5.4", "high", - undefined - ); - }); - - it("notifies Slack and skips prompt delivery when session creation fails", async () => { - vi.mocked(createSession).mockResolvedValue(null); - const env = makeEnv(); - - await expect( - startSessionAndSendPrompt(env, { - target: repositoryTarget, - channel: "C123", - threadTs: "111.222", - messageText: "Fix it", - actor, - }) - ).resolves.toBeNull(); - - expect(postMessage).toHaveBeenCalledWith( - "xoxb-test", - "C123", - "Sorry, I couldn't create a session. Please try again.", - { thread_ts: "111.222" } + undefined, + null ); - expect(deliverPrompt).not.toHaveBeenCalled(); - expect(storeThreadSession).not.toHaveBeenCalled(); - }); - - it("notifies Slack and avoids storing thread state when prompt delivery fails", async () => { - vi.mocked(deliverPrompt).mockResolvedValue({ ok: false, reason: "transient" }); - const env = makeEnv(); - - await expect( - startSessionAndSendPrompt(env, { - target: repositoryTarget, - channel: "C123", - threadTs: "111.222", - messageText: "Fix it", - actor, - }) - ).resolves.toBeNull(); - - expect(postMessage).toHaveBeenCalledWith( - "xoxb-test", - "C123", - "Session created but failed to send prompt. Please try again.", - { thread_ts: "111.222" } - ); - expect(storeThreadSession).not.toHaveBeenCalled(); }); it("downloads message images before session creation and hands them to delivery", async () => { diff --git a/packages/slack-bot/src/sessions/session-launcher.ts b/packages/slack-bot/src/sessions/session-launcher.ts index dd5e301c7c..1575a6916e 100644 --- a/packages/slack-bot/src/sessions/session-launcher.ts +++ b/packages/slack-bot/src/sessions/session-launcher.ts @@ -1,4 +1,4 @@ -import { postMessage } from "@open-inspect/shared/slack"; +import { escapeMrkdwnText, postMessage } from "@open-inspect/shared/slack"; import type { CallbackContext } from "@open-inspect/shared/types/session-api"; import { normalizeValidModels, type ValidModel } from "@open-inspect/shared/models"; import { getAuthoritativeModels, getAvailableModels } from "../app-home/models"; @@ -76,6 +76,7 @@ export async function loadAuthoritativeSlackLaunchSettings( export interface StartSessionOptions { target: SlackSessionTarget; + teamId?: string | null; channel: string; threadTs: string; messageText: string; @@ -113,6 +114,7 @@ export async function startSessionAndSendPrompt( ): Promise { const { target, + teamId, channel, threadTs, messageText, @@ -193,6 +195,7 @@ export async function startSessionAndSendPrompt( const session = await createSession(env, { target, + teamId, model, reasoningEffort, branch, @@ -201,13 +204,23 @@ export async function startSessionAndSendPrompt( actorDisplayName: actor.displayName, actorEmail: actor.email, }); - if (!session) { - await postMessage( - env.SLACK_BOT_TOKEN, - channel, - "Sorry, I couldn't create a session. Please try again.", - { thread_ts: threadTs } - ); + if (!session || "error" in session) { + const failure = session?.error; + let message = "Sorry, I couldn't create a session. Please try again."; + if ( + failure?.status === 403 && + (failure.code === "not_member" || + (failure.code === "session_action_denied" && failure.reasonCode === "not_member")) + ) { + message = "you are not a member of this channel's team"; + } else if ( + failure?.status === 409 && + failure.code === "target_team_missing_grant" && + failure.repository + ) { + message = `This channel's team does not have access to repository ${escapeMrkdwnText(failure.repository)}.`; + } + await postMessage(env.SLACK_BOT_TOKEN, channel, message, { thread_ts: threadTs }); return null; } @@ -232,9 +245,7 @@ export async function startSessionAndSendPrompt( attachments: preparedImages, imageOnly: Boolean(imageOnly), callbackContext, - // Normally empty — the session was just created with these settings. It is - // set only when recovering a stale thread, where the replacement keeps the - // thread's defaults and the follow-up's own flags stay a one-turn override. + // Usually empty: session-opening flags already became session defaults. ...firstPrompt.turnPlan.promptOverrides, channel, threadTs, @@ -257,7 +268,7 @@ export async function startSessionAndSendPrompt( env, channel, threadTs, - buildThreadSession(session.sessionId, target, model, reasoningEffort, messageTs) + buildThreadSession(session.sessionId, target, model, reasoningEffort, messageTs, teamId) ); return { sessionId: session.sessionId, sessionDefaults, differsFromUserDefaults }; } diff --git a/packages/slack-bot/src/sessions/thread-session-store.test.ts b/packages/slack-bot/src/sessions/thread-session-store.test.ts index 82255b8cad..26dc34bac9 100644 --- a/packages/slack-bot/src/sessions/thread-session-store.test.ts +++ b/packages/slack-bot/src/sessions/thread-session-store.test.ts @@ -4,7 +4,12 @@ import { advanceLastPromptTs, buildThreadSession, clearThreadSession, + closeThreadSession, + isThreadClosureNoticeSent, + isThreadSessionClosed, lookupThreadSession, + markThreadClosureNoticeSent, + reopenThreadSession, storeThreadSession, } from "./thread-session-store"; @@ -21,38 +26,153 @@ function makeEnv() { describe("thread session store", () => { let mocks: ReturnType; + const baseSession: ThreadSession = { + sessionId: "session-1", + repoId: "acme/app", + repoFullName: "acme/app", + model: "openai/gpt-5.4", + createdAt: 123, + }; beforeEach(() => { mocks = makeEnv(); }); - it("stores sessions under the thread key for seven days", async () => { - const session: ThreadSession = { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - }; + function useMemoryKv() { + const values = new Map(); + mocks.get.mockImplementation(async (key: string, type?: string) => { + const value = values.get(key); + return value === undefined ? null : type === "json" ? JSON.parse(value) : value; + }); + mocks.put.mockImplementation(async (key: string, value: string) => { + values.set(key, value); + }); + mocks.deleteValue.mockImplementation(async (key: string) => { + values.delete(key); + }); + return values; + } + + it("retains an early closure when the initial mapping is stored later", async () => { + const values = useMemoryKv(); + await closeThreadSession(mocks.env, "C123", "111.222", "session-1"); + expect(await isThreadSessionClosed(mocks.env, "C123", "111.222", "session-1")).toBe(true); + const session = { ...baseSession, teamId: null }; + await storeThreadSession(mocks.env, "C123", "111.222", session); + expect(JSON.parse(values.get("thread:C123:111.222")!)).toEqual({ ...session, closed: true }); + expect(await lookupThreadSession(mocks.env, "C123", "111.222")).toEqual({ + ...session, + closed: true, + }); + expect(mocks.put).toHaveBeenCalledWith("thread-closed:C123:111.222:session-1", "1", { + expirationTtl: 7 * 24 * 60 * 60, + }); + }); + + it("overlays closure after a stale checkpoint write overwrites the closed mapping", async () => { + const values = useMemoryKv(); + const session = { ...baseSession, lastPromptTs: "222.333" }; + await storeThreadSession(mocks.env, "C123", "111.222", session); + let releaseCheckpoint!: () => void; + let checkpointStarted!: () => void; + const paused = new Promise((resolve) => { + releaseCheckpoint = resolve; + }); + const started = new Promise((resolve) => { + checkpointStarted = resolve; + }); + mocks.put.mockImplementation(async (key: string, value: string) => { + if (key === "thread:C123:111.222" && JSON.parse(value).lastPromptTs === "333.444") { + checkpointStarted(); + await paused; + } + values.set(key, value); + }); + const checkpoint = advanceLastPromptTs(mocks.env, "C123", "111.222", "333.444"); + await started; + await closeThreadSession(mocks.env, "C123", "111.222", "session-1"); + releaseCheckpoint(); + await checkpoint; + expect(JSON.parse(values.get("thread:C123:111.222")!)).not.toHaveProperty("closed"); + expect(await lookupThreadSession(mocks.env, "C123", "111.222")).toMatchObject({ + closed: true, + lastPromptTs: "333.444", + }); + expect(await isThreadSessionClosed(mocks.env, "C123", "111.222", "session-1")).toBe(true); + }); + + it("keeps closure scoped to the session rather than poisoning a replacement mapping", async () => { + useMemoryKv(); + await closeThreadSession(mocks.env, "C123", "111.222", "old-session"); + const session = { ...baseSession, sessionId: "new-session" }; + await storeThreadSession(mocks.env, "C123", "111.222", session); + expect(await lookupThreadSession(mocks.env, "C123", "111.222")).toEqual(session); + expect(await isThreadSessionClosed(mocks.env, "C123", "111.222", "new-session")).toBe(false); + expect(await isThreadSessionClosed(mocks.env, "C123", "111.222", "old-session")).toBe(true); + }); + it("reopens a closed thread so a later closure notifies again", async () => { + useMemoryKv(); + const session = { ...baseSession, teamId: null }; await storeThreadSession(mocks.env, "C123", "111.222", session); + await closeThreadSession(mocks.env, "C123", "111.222", "session-1"); + await markThreadClosureNoticeSent(mocks.env, "C123", "111.222", "session-1"); + + await expect( + reopenThreadSession(mocks.env, "C123", "111.222", { ...session, closed: true }) + ).resolves.toEqual(session); + expect(await lookupThreadSession(mocks.env, "C123", "111.222")).toEqual(session); + expect(await isThreadClosureNoticeSent(mocks.env, "C123", "111.222", "session-1")).toBe(false); + + await closeThreadSession(mocks.env, "C123", "111.222", "session-1"); + expect(await isThreadSessionClosed(mocks.env, "C123", "111.222", "session-1")).toBe(true); + }); + + it("keeps a closure that lands while a reopen rewrites the mapping", async () => { + const values = useMemoryKv(); + const session = { ...baseSession, teamId: null }; + await storeThreadSession(mocks.env, "C123", "111.222", session); + await closeThreadSession(mocks.env, "C123", "111.222", "session-1"); + let releaseRewrite!: () => void; + let rewriteStarted!: () => void; + const paused = new Promise((resolve) => { + releaseRewrite = resolve; + }); + const started = new Promise((resolve) => { + rewriteStarted = resolve; + }); + mocks.put.mockImplementation(async (key: string, value: string) => { + if (key === "thread:C123:111.222" && !("closed" in JSON.parse(value))) { + rewriteStarted(); + await paused; + } + values.set(key, value); + }); + const reopen = reopenThreadSession(mocks.env, "C123", "111.222", { ...session, closed: true }); + await started; + await closeThreadSession(mocks.env, "C123", "111.222", "session-1"); + releaseRewrite(); + await reopen; + expect(JSON.parse(values.get("thread:C123:111.222")!)).not.toHaveProperty("closed"); + expect(await lookupThreadSession(mocks.env, "C123", "111.222")).toEqual({ + ...session, + closed: true, + }); + expect(await isThreadSessionClosed(mocks.env, "C123", "111.222", "session-1")).toBe(true); + }); + + it("stores sessions under the thread key for seven days", async () => { + await storeThreadSession(mocks.env, "C123", "111.222", baseSession); - expect(mocks.put).toHaveBeenCalledWith("thread:C123:111.222", JSON.stringify(session), { + expect(mocks.put).toHaveBeenCalledWith("thread:C123:111.222", JSON.stringify(baseSession), { expirationTtl: 7 * 24 * 60 * 60, }); }); it("reads and clears sessions using the same key", async () => { - const session: ThreadSession = { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - }; - mocks.get.mockResolvedValue(session); + mocks.get.mockResolvedValue(baseSession); - await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(session); + await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(baseSession); expect(mocks.get).toHaveBeenCalledWith("thread:C123:111.222", "json"); await clearThreadSession(mocks.env, "C123", "111.222"); @@ -118,29 +238,9 @@ describe("thread session store", () => { {}, [], { sessionId: "session-1" }, - { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: "123", - }, - { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - reasoningEffort: 123, - createdAt: 123, - }, - { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - lastPromptTs: 333.444, - }, + { ...baseSession, createdAt: "123" }, + { ...baseSession, reasoningEffort: 123 }, + { ...baseSession, lastPromptTs: 333.444 }, ])("rejects malformed records: %j", async (record) => { mocks.get.mockResolvedValue(record); @@ -148,83 +248,33 @@ describe("thread session store", () => { }); it("accepts persisted records with and without reasoning effort", async () => { - const base: ThreadSession = { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - }; - const withReasoning = { ...base, reasoningEffort: "high" }; - mocks.get.mockResolvedValueOnce(base).mockResolvedValueOnce(withReasoning); + const withReasoning = { ...baseSession, reasoningEffort: "high" }; + const records = [baseSession, withReasoning]; + mocks.get.mockImplementation(async (_key: string, type?: string) => + type === "json" ? records.shift() : null + ); - await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(base); + await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(baseSession); await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(withReasoning); }); it("accepts persisted records with and without a last prompt ts", async () => { - const base: ThreadSession = { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - }; - const withLastPrompt = { ...base, lastPromptTs: "333.444" }; - mocks.get.mockResolvedValueOnce(base).mockResolvedValueOnce(withLastPrompt); + const withLastPrompt = { ...baseSession, lastPromptTs: "333.444" }; + const records = [baseSession, withLastPrompt]; + mocks.get.mockImplementation(async (_key: string, type?: string) => + type === "json" ? records.shift() : null + ); - await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(base); + await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual(baseSession); await expect(lookupThreadSession(mocks.env, "C123", "111.222")).resolves.toEqual( withLastPrompt ); }); describe("advanceLastPromptTs", () => { - const stored: ThreadSession = { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - lastPromptTs: "222.333", - }; - - it("advances the checkpoint when the new ts is newer", async () => { - mocks.get.mockResolvedValue(stored); - - await advanceLastPromptTs(mocks.env, "C123", "111.222", "333.444"); - - expect(mocks.put).toHaveBeenCalledWith( - "thread:C123:111.222", - JSON.stringify({ ...stored, lastPromptTs: "333.444" }), - { expirationTtl: 7 * 24 * 60 * 60 } - ); - }); - - it("stamps mappings that have no checkpoint yet", async () => { - const { lastPromptTs: _legacy, ...legacy } = stored; - mocks.get.mockResolvedValue(legacy); - - await advanceLastPromptTs(mocks.env, "C123", "111.222", "333.444"); - - expect(mocks.put).toHaveBeenCalledWith( - "thread:C123:111.222", - JSON.stringify({ ...legacy, lastPromptTs: "333.444" }), - { expirationTtl: 7 * 24 * 60 * 60 } - ); - }); - - it("does not move the checkpoint backwards on out-of-order completion", async () => { - mocks.get.mockResolvedValue({ ...stored, lastPromptTs: "999.999" }); - - await advanceLastPromptTs(mocks.env, "C123", "111.222", "333.444"); - - expect(mocks.put).not.toHaveBeenCalled(); - }); - it("keeps the checkpoint monotonic across exact microsecond fractions", async () => { mocks.get.mockResolvedValue({ - ...stored, + ...baseSession, lastPromptTs: "9999999999999999.000002", }); @@ -243,18 +293,11 @@ describe("thread session store", () => { }); it("handles KV write and delete failures", async () => { - const session: ThreadSession = { - sessionId: "session-1", - repoId: "acme/app", - repoFullName: "acme/app", - model: "openai/gpt-5.4", - createdAt: 123, - }; mocks.put.mockRejectedValue(new Error("KV write unavailable")); mocks.deleteValue.mockRejectedValue(new Error("KV delete unavailable")); await expect( - storeThreadSession(mocks.env, "C123", "111.222", session) + storeThreadSession(mocks.env, "C123", "111.222", baseSession) ).resolves.toBeUndefined(); await expect(clearThreadSession(mocks.env, "C123", "111.222")).resolves.toBeUndefined(); }); diff --git a/packages/slack-bot/src/sessions/thread-session-store.ts b/packages/slack-bot/src/sessions/thread-session-store.ts index f4f8d64150..b1e07e3684 100644 --- a/packages/slack-bot/src/sessions/thread-session-store.ts +++ b/packages/slack-bot/src/sessions/thread-session-store.ts @@ -6,9 +6,12 @@ import { targetId, targetLabel, type SlackSessionTarget } from "../targets"; import type { Env, ThreadSession } from "../types"; const log = createLogger("handler"); +export const THREAD_CLOSED_MESSAGE = "this session is no longer available from this channel"; const THREAD_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const threadSessionSchema: z.ZodType = z.object({ sessionId: z.string().min(1), + teamId: z.string().min(1).nullable().optional(), + closed: z.literal(true).optional(), repoId: z.string().min(1), repoFullName: z.string().min(1), model: z.string().min(1), @@ -21,6 +24,31 @@ function getThreadSessionKey(channel: string, threadTs: string): string { return `thread:${channel}:${threadTs}`; } +function getThreadClosureKey(channel: string, threadTs: string, sessionId: string): string { + return `thread-closed:${channel}:${threadTs}:${sessionId}`; +} + +function getThreadClosureNoticeKey(channel: string, threadTs: string, sessionId: string): string { + return `${getThreadClosureKey(channel, threadTs, sessionId)}:notice`; +} + +function withoutClosure({ closed: _closed, ...session }: ThreadSession): ThreadSession { + return session; +} + +async function hasThreadClosure( + env: Env, + channel: string, + threadTs: string, + sessionId: string +): Promise { + return ( + (await createKvCacheStore(env.SLACK_KV).get( + getThreadClosureKey(channel, threadTs, sessionId) + )) === "1" + ); +} + export async function lookupThreadSession( env: Env, channel: string, @@ -32,7 +60,12 @@ export async function lookupThreadSession( "json" ); const result = threadSessionSchema.safeParse(data); - return result.success ? result.data : null; + if (!result.success) return null; + const session = result.data; + if (!session.closed && (await hasThreadClosure(env, channel, threadTs, session.sessionId))) { + return { ...session, closed: true }; + } + return session; } catch (e) { log.error("kv.get", { key_prefix: "thread", @@ -51,6 +84,9 @@ export async function storeThreadSession( session: ThreadSession ): Promise { try { + if (!session.closed && (await hasThreadClosure(env, channel, threadTs, session.sessionId))) { + session = { ...session, closed: true }; + } await createKvCacheStore(env.SLACK_KV).put( getThreadSessionKey(channel, threadTs), JSON.stringify(session), @@ -66,6 +102,86 @@ export async function storeThreadSession( } } +/** Also checks coordinate-only closures, where an automation has no interactive mapping. */ +export async function isThreadSessionClosed( + env: Env, + channel: string, + threadTs: string, + sessionId: string +): Promise { + const mapping = await lookupThreadSession(env, channel, threadTs); + if (mapping?.sessionId === sessionId) return mapping.closed === true; + return hasThreadClosure(env, channel, threadTs, sessionId); +} + +/** The independent tombstone survives absent mappings and stale whole-record writes. */ +export async function closeThreadSession( + env: Env, + channel: string, + threadTs: string, + sessionId: string +): Promise { + if (await hasThreadClosure(env, channel, threadTs, sessionId)) return; + // Do not swallow marker failures: callback callers must return a retryable response. + await createKvCacheStore(env.SLACK_KV).put( + getThreadClosureKey(channel, threadTs, sessionId), + "1", + { + expirationTtl: THREAD_SESSION_TTL_MS / 1000, + } + ); + const mapping = await lookupThreadSession(env, channel, threadTs); + if (mapping?.sessionId === sessionId) { + await storeThreadSession(env, channel, threadTs, { ...mapping, closed: true }); + } +} + +/** + * Lifts a closure after the caller re-verified the channel binding and publication access: + * both can change back, so a closure must not outlive them. The tombstone goes first so the + * mapping rewrite can drop `closed`; a closure written meanwhile wins through its own tombstone. + */ +export async function reopenThreadSession( + env: Env, + channel: string, + threadTs: string, + session: ThreadSession +): Promise { + const store = createKvCacheStore(env.SLACK_KV); + await store.delete(getThreadClosureKey(channel, threadTs, session.sessionId)); + await store.delete(getThreadClosureNoticeKey(channel, threadTs, session.sessionId)); + const mapping = await lookupThreadSession(env, channel, threadTs); + if (mapping?.sessionId === session.sessionId && mapping.closed) { + await storeThreadSession(env, channel, threadTs, withoutClosure(mapping)); + } + return withoutClosure(session); +} + +/** Best-effort sent-marker lookup, not an atomic delivery claim. */ +export async function isThreadClosureNoticeSent( + env: Env, + channel: string, + threadTs: string, + sessionId: string +): Promise { + const key = getThreadClosureNoticeKey(channel, threadTs, sessionId); + return (await createKvCacheStore(env.SLACK_KV).get(key)) === "1"; +} + +/** Called only after Slack confirms the notice was posted. */ +export async function markThreadClosureNoticeSent( + env: Env, + channel: string, + threadTs: string, + sessionId: string +): Promise { + await createKvCacheStore(env.SLACK_KV).put( + getThreadClosureNoticeKey(channel, threadTs, sessionId), + "1", + { expirationTtl: THREAD_SESSION_TTL_MS / 1000 } + ); +} + export async function clearThreadSession( env: Env, channel: string, @@ -90,8 +206,8 @@ export async function clearThreadSession( * the mapping is re-read and only written when the new ts is strictly newer. * KV has no compare-and-swap, so truly simultaneous writes can still race in * a narrow window; a lost race only re-includes a few already-forwarded - * thread messages in a later prompt. No-op when the mapping is gone (e.g. - * concurrently cleared) so a dead session is never resurrected. + * thread messages in a later prompt. No-op when the mapping is gone or closed; + * the separate tombstone preserves closure across stale checkpoint writes. */ export async function advanceLastPromptTs( env: Env, @@ -100,7 +216,7 @@ export async function advanceLastPromptTs( promptTs: string ): Promise { const current = await lookupThreadSession(env, channel, threadTs); - if (!current) return; + if (!current || current.closed) return; if (current.lastPromptTs && compareSlackTimestamps(current.lastPromptTs, promptTs) >= 0) return; await storeThreadSession(env, channel, threadTs, { ...current, lastPromptTs: promptTs }); } @@ -110,7 +226,8 @@ export function buildThreadSession( target: SlackSessionTarget, model: string, reasoningEffort?: string, - lastPromptTs?: string + lastPromptTs?: string, + teamId?: string | null ): ThreadSession { return { sessionId, @@ -120,5 +237,6 @@ export function buildThreadSession( reasoningEffort, createdAt: Date.now(), lastPromptTs, + ...(teamId !== undefined ? { teamId } : {}), }; } diff --git a/packages/slack-bot/src/target-clarification.test.ts b/packages/slack-bot/src/target-clarification.test.ts index 2e64d032e2..80f9a0a41d 100644 --- a/packages/slack-bot/src/target-clarification.test.ts +++ b/packages/slack-bot/src/target-clarification.test.ts @@ -102,23 +102,6 @@ describe("filterReposByQuery", () => { }); describe("buildTargetQuickPickButtons", () => { - it("maps alternatives to quick-pick buttons carrying the repo id", () => { - expect(buildTargetQuickPickButtons([repoTarget("acme/web"), repoTarget("acme/api")])).toEqual([ - { - type: "button", - action_id: quickPickActionId(0), - text: { type: "plain_text", text: "web" }, - value: "acme/web", - }, - { - type: "button", - action_id: quickPickActionId(1), - text: { type: "plain_text", text: "api" }, - value: "acme/api", - }, - ]); - }); - it("maps an environment alternative to a button carrying the env: value", () => { expect(buildTargetQuickPickButtons([environmentTarget("env_abc123", "full-stack")])).toEqual([ { @@ -198,15 +181,18 @@ describe("resolveTargetValue", () => { }); it("resolves a repository value against the live repo list", async () => { - expect(await resolveTargetValue(env, "acme/web")).toEqual(target); + expect(await resolveTargetValue(env, "acme/web", "trace", "C1", "U123")).toEqual(target); + expect(mockGetAvailableRepos).toHaveBeenCalledWith(env, "trace", "C1", "U123"); }); it("resolves an env: value against the live environments", async () => { mockGetEnvironmentById.mockResolvedValue( envTarget.kind === "environment" ? envTarget.environment : null ); - expect(await resolveTargetValue(env, "env:env_abc123")).toEqual(envTarget); - expect(mockGetEnvironmentById).toHaveBeenCalledWith(env, "env_abc123", undefined); + expect(await resolveTargetValue(env, "env:env_abc123", "trace", "C1", "U123")).toEqual( + envTarget + ); + expect(mockGetEnvironmentById).toHaveBeenCalledWith(env, "env_abc123", "trace", "C1", "U123"); }); it("returns null for a repository or environment that no longer exists", async () => { @@ -264,8 +250,10 @@ describe("getTargetClarificationOptions", () => { mockGetAvailableEnvironments.mockResolvedValue([]); }); - it("returns flat options while the workspace is repository-only", async () => { - const response = await getTargetClarificationOptions(env, undefined); + it("returns flat options while the channel catalog is repository-only", async () => { + const response = await getTargetClarificationOptions(env, undefined, "trace", "C1", "U123"); + expect(mockGetAvailableRepos).toHaveBeenCalledWith(env, "trace", "C1", "U123"); + expect(mockGetAvailableEnvironments).toHaveBeenCalledWith(env, "trace", "C1", "U123"); expect(response).toEqual({ options: [ { @@ -455,8 +443,18 @@ describe("buildTargetClarificationBlocks", () => { type: "actions", block_id: targetQuickPickBlockId(REQUEST_ID), elements: [ - { type: "button", action_id: quickPickActionId(0), value: "acme/web" }, - { type: "button", action_id: quickPickActionId(1), value: "acme/api" }, + { + type: "button", + action_id: quickPickActionId(0), + text: { type: "plain_text", text: "web" }, + value: "acme/web", + }, + { + type: "button", + action_id: quickPickActionId(1), + text: { type: "plain_text", text: "api" }, + value: "acme/api", + }, ], }, { diff --git a/packages/slack-bot/src/target-clarification.ts b/packages/slack-bot/src/target-clarification.ts index e45d99f9f3..55c309eb25 100644 --- a/packages/slack-bot/src/target-clarification.ts +++ b/packages/slack-bot/src/target-clarification.ts @@ -164,17 +164,25 @@ function filterEnvironmentsByQuery( export async function resolveTargetValue( env: Env, value: string, - traceId?: string + traceId?: string, + channelId?: string | null, + userId?: string ): Promise { const ref = parseTargetValue(value); if (ref.kind === "none") { return { kind: "none" }; } if (ref.kind === "environment") { - const environment = await getEnvironmentById(env, ref.environmentId, traceId); + const environment = await getEnvironmentById( + env, + ref.environmentId, + traceId, + channelId, + userId + ); return environment ? { kind: "environment", environment } : null; } - const repos = await getAvailableRepos(env, traceId); + const repos = await getAvailableRepos(env, traceId, channelId, userId); const repo = repos.find((r) => r.id === ref.repoId); return repo ? { kind: "repository", repo } : null; } @@ -240,9 +248,11 @@ function buildGroupedOptions( export async function getTargetClarificationOptions( env: Env, query: string | undefined, - traceId?: string + traceId?: string, + channelId?: string | null, + userId?: string ): Promise { - const catalog = await loadTargetCatalog(env, traceId); + const catalog = await loadTargetCatalog(env, traceId, channelId, userId); const remainingAfterNoRepository = MAX_REPO_SUGGESTION_OPTIONS - 1; const matchedEnvironments = filterEnvironmentsByQuery(catalog.environments, query).slice( 0, diff --git a/packages/slack-bot/src/types.ts b/packages/slack-bot/src/types.ts index 44b28b5cea..718b8c2539 100644 --- a/packages/slack-bot/src/types.ts +++ b/packages/slack-bot/src/types.ts @@ -55,6 +55,8 @@ export interface Env { */ export interface ThreadContext { channelId: string; + teamId?: string | null; + userId?: string; channelName?: string; channelDescription?: string; threadTs?: string; @@ -98,6 +100,9 @@ export type BackgroundTaskScheduler = (promise: Promise) => void; */ export interface ThreadSession { sessionId: string; + /** Missing only on mappings persisted before channel bindings. */ + teamId?: string | null; + closed?: true; /** Session-target id: a repo id, environment id, or the no-repository sentinel. */ repoId: string; /** Session-target display label, including `No repository` for an empty sandbox. */ diff --git a/packages/web/src/app/api/teams/[id]/channel-bindings/route.ts b/packages/web/src/app/api/teams/[id]/channel-bindings/route.ts new file mode 100644 index 0000000000..4ad2eb5779 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/channel-bindings/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET } = settingsProxy( + ({ id }: { id: string }) => `/teams/${encodeURIComponent(id)}/channel-bindings`, + "team channel bindings" +); diff --git a/packages/web/src/app/api/teams/[id]/channel-bindings/slack/[channelId]/route.ts b/packages/web/src/app/api/teams/[id]/channel-bindings/slack/[channelId]/route.ts new file mode 100644 index 0000000000..1358db08ae --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/channel-bindings/slack/[channelId]/route.ts @@ -0,0 +1,7 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { PUT, DELETE } = settingsProxy( + ({ id, channelId }: { id: string; channelId: string }) => + `/teams/${encodeURIComponent(id)}/channel-bindings/slack/${encodeURIComponent(channelId)}`, + "team channel binding" +); diff --git a/packages/web/src/app/api/teams/[id]/slack-channels/route.ts b/packages/web/src/app/api/teams/[id]/slack-channels/route.ts new file mode 100644 index 0000000000..5b10d5921e --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/slack-channels/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET } = settingsProxy( + ({ id }: { id: string }) => `/teams/${encodeURIComponent(id)}/slack-channels`, + "team Slack channels" +); diff --git a/packages/web/src/components/settings/audit-log-settings.test.tsx b/packages/web/src/components/settings/audit-log-settings.test.tsx index 3a8fa65a33..1965227874 100644 --- a/packages/web/src/components/settings/audit-log-settings.test.tsx +++ b/packages/web/src/components/settings/audit-log-settings.test.tsx @@ -199,6 +199,8 @@ describe("AuditLogSettings", () => { ["team.grant_removed", "Team repository grant removed"], ["team.secret_set", "Team secret set"], ["team.secret_deleted", "Team secret deleted"], + ["team.binding_added", "Team channel binding added"], + ["team.binding_removed", "Team channel binding removed"], ["automation.executor_changed", "Automation executor changed"], ])("labels %s as an operation in the workspace audit viewer", (action, label) => { const article = renderSingle(createEvent("applied", { action })); diff --git a/packages/web/src/components/settings/audit-log-settings.tsx b/packages/web/src/components/settings/audit-log-settings.tsx index b3cc683587..21ee4a61cf 100644 --- a/packages/web/src/components/settings/audit-log-settings.tsx +++ b/packages/web/src/components/settings/audit-log-settings.tsx @@ -65,6 +65,8 @@ const OPERATION_LABELS: Record = { "team.grant_removed": "Team repository grant removed", "team.secret_set": "Team secret set", "team.secret_deleted": "Team secret deleted", + "team.binding_added": "Team channel binding added", + "team.binding_removed": "Team channel binding removed", "automation.executor_changed": "Automation executor changed", }; diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx index 0056d88dd1..411ccbbfae 100644 --- a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx +++ b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx @@ -2,7 +2,7 @@ /// import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, render, screen, within } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { EnrichedRepository } from "@open-inspect/shared/types/repository-catalog"; @@ -14,8 +14,12 @@ import { } from "@open-inspect/shared/types/integrations"; import { SlackIntegrationSettings } from "./slack-integration-settings"; +const authorization = vi.hoisted(() => ({ canManageGlobal: true })); vi.mock("@/hooks/use-current-user-authorization", () => ({ - useCurrentUserAuthorization: () => ({ hasPermission: () => true }), + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission !== "integrations.manage" || authorization.canManageGlobal, + }), })); vi.mock("@/hooks/use-enabled-models", () => ({ @@ -141,6 +145,7 @@ beforeAll(() => { }); beforeEach(() => { + authorization.canManageGlobal = true; fetchMock.mockReset(); toastSuccess.mockReset(); toastError.mockReset(); @@ -173,21 +178,54 @@ describe("SlackIntegrationSettings", () => { render(); expect(screen.getByRole("heading", { name: "Slack" })).toBeInTheDocument(); + expect( + screen.getByText(/invite the .*slack.* bot to a channel/i, { selector: "p" }) + ).toBeInTheDocument(); const masterSwitch = screen.getByRole("switch", { name: /enable agent notifications/i }); expect(masterSwitch).toHaveAttribute("aria-checked", "false"); const allowRadio = screen.getByRole("radio", { name: /allow/i }) as HTMLInputElement; expect(allowRadio.checked).toBe(true); + expect(screen.getByRole("combobox", { name: "Unbound channels" })).toHaveValue("workspace"); + expect(screen.getByLabelText(/session instructions/i)).toHaveAttribute("maxlength", "10000"); }); - it("describes channel access via Slack bot membership in help copy", () => { + it("disables the unbound policy without global integration-management permission", () => { + authorization.canManageGlobal = false; setupSWR({ global: null }); render(); + expect(screen.getByRole("combobox", { name: "Unbound channels" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); - expect( - screen.getByText(/invite the .*slack.* bot to a channel/i, { selector: "p" }) - ).toBeInTheDocument(); + it("resets the unbound policy to workspace while preserving routing rules", async () => { + const user = userEvent.setup(); + const routingRules = [{ keyword: "frontend", target: "acme/web" }]; + setupSWR({ + global: { defaults: { unboundChannels: "reject", routingRules } }, + availableRepos: [repo("acme/web")], + }); + fetchMock.mockResolvedValue(okJson({})); + mutateMock.mockImplementation((_key: string, data: { settings: SlackGlobalConfig }) => { + setupSWR({ global: data.settings, availableRepos: [repo("acme/web")] }); + }); + const { rerender } = render(); + await user.click(screen.getByRole("button", { name: "Reset to defaults" })); + await user.click(screen.getByRole("button", { name: "Reset" })); + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ + settings: { defaults: { routingRules } }, + }); + await waitFor(() => + expect(mutateMock).toHaveBeenCalledWith("/api/integration-settings/slack", { + settings: { defaults: { routingRules } }, + }) + ); + rerender(); + await waitFor(() => + expect(screen.getByRole("combobox", { name: "Unbound channels" })).toHaveValue("workspace") + ); }); it("toggling master switch on and saving sends agentNotificationsEnabled: true", async () => { @@ -208,7 +246,10 @@ describe("SlackIntegrationSettings", () => { expect(body.settings.defaults).toEqual({ agentNotificationsEnabled: true, mentionsPolicy: "allow", + unboundChannels: "workspace", }); + // The routing editor merges against this saved cache snapshot. + expect(mutateMock).toHaveBeenCalledWith("/api/integration-settings/slack", body); }); it("changing mentions policy radio sends the new value on save", async () => { @@ -269,6 +310,7 @@ describe("SlackIntegrationSettings", () => { agentNotificationsEnabled: true, mentionsPolicy: "strip", routingRules: [{ keyword: "frontend", target: "acme/web" }], + unboundChannels: "workspace", }); }); @@ -279,6 +321,7 @@ describe("SlackIntegrationSettings", () => { defaults: { agentNotificationsEnabled: true, mentionsPolicy: "strip", + model: "openai/gpt-5.4", routingRules: [{ keyword: "frontend", target: "acme/web" }], }, }, @@ -288,6 +331,9 @@ describe("SlackIntegrationSettings", () => { render(); + expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled(); + await user.selectOptions(screen.getByRole("combobox", { name: "Unbound channels" }), "reject"); + expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled(); await user.type(screen.getByLabelText(/session instructions/i), "Prefer minimal diffs."); await user.click(screen.getByRole("button", { name: /^save$/i })); @@ -296,36 +342,10 @@ describe("SlackIntegrationSettings", () => { expect(body.settings.defaults).toEqual({ agentNotificationsEnabled: true, mentionsPolicy: "strip", + model: "openai/gpt-5.4", routingRules: [{ keyword: "frontend", target: "acme/web" }], sessionInstructions: "Prefer minimal diffs.", - }); - }); - - it("bounds the session instructions textarea to the shared maximum length", () => { - setupSWR({ global: null }); - - render(); - - expect(screen.getByLabelText(/session instructions/i)).toHaveAttribute("maxlength", "10000"); - }); - - // Regression: a save must seed the SWR cache with the saved blob. The other - // global section merges against this snapshot, so leaving the pre-save data - // in place would let a back-to-back save silently drop the new values. - it("seeds the SWR cache with the saved defaults on save", async () => { - const user = userEvent.setup(); - setupSWR({ global: null }); - fetchMock.mockResolvedValue(okJson({})); - - render(); - - await user.click(screen.getByRole("switch", { name: /enable agent notifications/i })); - await user.click(screen.getByRole("button", { name: /^save$/i })); - - expect(mutateMock).toHaveBeenCalledWith("/api/integration-settings/slack", { - settings: { - defaults: { agentNotificationsEnabled: true, mentionsPolicy: "allow" }, - }, + unboundChannels: "reject", }); }); @@ -352,6 +372,7 @@ describe("SlackIntegrationSettings", () => { expect(body.settings.defaults).toEqual({ agentNotificationsEnabled: true, mentionsPolicy: "strip", + unboundChannels: "workspace", }); }); @@ -460,6 +481,7 @@ describe("SlackIntegrationSettings", () => { agentNotificationsEnabled: true, mentionsPolicy: "strip", sessionInstructions: "Prefer minimal diffs.", + unboundChannels: "reject", }, }, }); @@ -472,6 +494,7 @@ describe("SlackIntegrationSettings", () => { ); expect((screen.getByRole("radio", { name: /strip/i }) as HTMLInputElement).checked).toBe(true); expect(screen.getByLabelText(/session instructions/i)).toHaveValue("Prefer minimal diffs."); + expect(screen.getByRole("combobox", { name: "Unbound channels" })).toHaveValue("reject"); }); // Regression: dirty edits must not be clobbered by SWR revalidation. @@ -480,6 +503,7 @@ describe("SlackIntegrationSettings", () => { setupSWR({ global: null }); const { rerender } = render(); + await user.selectOptions(screen.getByRole("combobox", { name: "Unbound channels" }), "reject"); await user.click(screen.getByRole("switch", { name: /enable agent notifications/i })); expect(screen.getByRole("switch", { name: /enable agent notifications/i })).toHaveAttribute( "aria-checked", @@ -488,7 +512,13 @@ describe("SlackIntegrationSettings", () => { act(() => { setupSWR({ - global: { defaults: { agentNotificationsEnabled: false, mentionsPolicy: "strip" } }, + global: { + defaults: { + agentNotificationsEnabled: false, + mentionsPolicy: "strip", + unboundChannels: "workspace", + }, + }, }); }); rerender(); @@ -498,6 +528,7 @@ describe("SlackIntegrationSettings", () => { "true" ); expect((screen.getByRole("radio", { name: /allow/i }) as HTMLInputElement).checked).toBe(true); + expect(screen.getByRole("combobox", { name: "Unbound channels" })).toHaveValue("reject"); }); // Regression: per-repo row must resync when entry.settings changes from SWR. @@ -567,7 +598,15 @@ describe("SlackIntegrationSettings", () => { it("adds a routing rule and saves it merged into the defaults", async () => { const user = userEvent.setup(); setupSWR({ - global: { defaults: { agentNotificationsEnabled: true, mentionsPolicy: "allow" } }, + global: { + defaults: { + agentNotificationsEnabled: true, + mentionsPolicy: "allow", + unboundChannels: "reject", + model: "openai/gpt-5.4", + sessionInstructions: "Prefer minimal diffs.", + }, + }, availableRepos: [repo("acme/web")], }); fetchMock.mockResolvedValue(okJson({})); @@ -592,38 +631,13 @@ describe("SlackIntegrationSettings", () => { expect(body.settings.defaults).toEqual({ agentNotificationsEnabled: true, mentionsPolicy: "allow", + unboundChannels: "reject", + model: "openai/gpt-5.4", + sessionInstructions: "Prefer minimal diffs.", routingRules: [{ keyword: "Frontend", target: "acme/web" }], }); }); - it("preserves existing routing rules when the Defaults section is saved", async () => { - const user = userEvent.setup(); - setupSWR({ - global: { - defaults: { - agentNotificationsEnabled: false, - mentionsPolicy: "allow", - routingRules: [{ keyword: "frontend", target: "acme/web" }], - }, - }, - availableRepos: [repo("acme/web")], - }); - fetchMock.mockResolvedValue(okJson({})); - render(); - - await user.click(screen.getByRole("switch", { name: /enable agent notifications/i })); - await user.click(screen.getByRole("button", { name: /^save$/i })); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { - settings: SlackGlobalConfig; - }; - expect(body.settings.defaults).toEqual({ - agentNotificationsEnabled: true, - mentionsPolicy: "allow", - routingRules: [{ keyword: "frontend", target: "acme/web" }], - }); - }); - it("omits routingRules on save after the last rule is removed", async () => { const user = userEvent.setup(); setupSWR({ diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx index 51db92585a..e60dba5a05 100644 --- a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx @@ -14,6 +14,7 @@ import type { ListEnvironmentsResponse, } from "@open-inspect/shared/types/environments"; import { + DEFAULT_SLACK_UNBOUND_CHANNELS, MAX_SESSION_INSTRUCTIONS_LENGTH, MAX_SLACK_ROUTING_RULES, type SlackGlobalConfig, @@ -199,6 +200,9 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul const [sessionInstructions, setSessionInstructions] = useState( settings?.defaults?.sessionInstructions ?? "" ); + const [unboundChannels, setUnboundChannels] = useState<"workspace" | "reject">( + settings?.defaults?.unboundChannels ?? DEFAULT_SLACK_UNBOUND_CHANNELS + ); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [showResetDialog, setShowResetDialog] = useState(false); @@ -209,6 +213,7 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul setModel(settings?.defaults?.model ?? ""); setMentionsPolicy(settings?.defaults?.mentionsPolicy ?? DEFAULT_MENTIONS_POLICY); setSessionInstructions(settings?.defaults?.sessionInstructions ?? ""); + setUnboundChannels(settings?.defaults?.unboundChannels ?? DEFAULT_SLACK_UNBOUND_CHANNELS); }, [settings, dirty, saving]); const selectedModelEnabled = model ? enabledModels.includes(model) : true; @@ -221,7 +226,7 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul const handleConfirmReset = async () => { setSaving(true); try { - // Reset only the notification/mention defaults. If routing rules exist, + // Reset the defaults edited in this section. If routing rules exist, // preserve them by writing a blob that keeps just the rules (rather than // deleting the whole row); otherwise clear the row entirely. const existingRules = settings?.defaults?.routingRules; @@ -243,6 +248,7 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul setModel(""); setMentionsPolicy(DEFAULT_MENTIONS_POLICY); setSessionInstructions(""); + setUnboundChannels(DEFAULT_SLACK_UNBOUND_CHANNELS); setDirty(false); toast.success("Settings reset to defaults."); } else { @@ -264,6 +270,7 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul model: model || undefined, mentionsPolicy, sessionInstructions: sessionInstructions || undefined, + unboundChannels, }), }; @@ -294,7 +301,7 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul return ( +
+ +

+ Choose what happens when a request comes from a Slack channel without a team binding. + Manage bindings in a team's Channels tab. +

+ +
+

Default model

@@ -432,8 +465,8 @@ function GlobalSettingsSection({ settings }: { settings: SlackGlobalConfig | nul Reset Slack defaults? The master switch will turn off, the default model will use the system default, mentions policy will return to allow, and session - instructions will be cleared. Per-repository overrides and routing rules are not - affected. + instructions will be cleared. Unbound channels will create workspace-level sessions. + Per-repository overrides and routing rules are not affected. diff --git a/packages/web/src/components/slack-notify-event.test.tsx b/packages/web/src/components/slack-notify-event.test.tsx index d7e8d89fdb..26c1d5d4c1 100644 --- a/packages/web/src/components/slack-notify-event.test.tsx +++ b/packages/web/src/components/slack-notify-event.test.tsx @@ -122,6 +122,16 @@ describe("SlackNotifyEvent", () => { expect(screen.getByText(/notifications are disabled for this repository/i)).toBeInTheDocument(); }); + it("renders session_scope_denied without suggesting bot invitations or showing a permalink", () => { + renderExpanded(denialEvent("session_scope_denied")); + expect( + screen.getByText(/not allowed to post to the requested Slack channel/i) + ).toBeInTheDocument(); + expect(screen.getByText(/visibility or the channel's team binding/i)).toBeInTheDocument(); + expect(screen.queryByText(/invite the/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /view in slack/i })).not.toBeInTheDocument(); + }); + it("renders rate_limited with retry-window copy", () => { renderExpanded(denialEvent("rate_limited")); expect(screen.getByText(/rate-limited/i)).toBeInTheDocument(); diff --git a/packages/web/src/components/slack-notify-event.tsx b/packages/web/src/components/slack-notify-event.tsx index d19d3180e5..625ef58f1f 100644 --- a/packages/web/src/components/slack-notify-event.tsx +++ b/packages/web/src/components/slack-notify-event.tsx @@ -23,6 +23,10 @@ const DENIAL_COPY: Record + +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, + within, +} from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { ReactNode } from "react"; +import { SWRConfig } from "swr"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TeamResponse } from "@/hooks/use-teams"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { TeamChannels } from "./team-channels"; +import { useSlackChannels } from "@/hooks/use-slack-channels"; + +expect.extend(matchers); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: { id: "user_one" } } }), +})); + +function wrapper({ children }: { children: ReactNode }) { + return ( + new Map(), + dedupingInterval: 0, + focusThrottleInterval: 0, + shouldRetryOnError: false, + }} + > + {children} + + ); +} + +const team: TeamResponse = { + id: "team/id", + slug: "design", + name: "Design", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + memberCount: 1, + capabilities: { + canJoin: false, + canLeave: false, + canEditMetadata: false, + canManageMembers: false, + canManageRepositories: false, + canManageBindings: true, + canManageAutomations: false, + canManageEnvironments: false, + canManageSecrets: false, + canArchive: false, + }, +}; +const key = "/api/teams/team%2Fid/channel-bindings"; +const channelsKey = "/api/teams/team%2Fid/slack-channels"; +const channels = [ + { id: "C_HOME", name: "home", isMember: true, isPrivate: false }, + { id: "C_SOURCE", name: "source", isMember: true, isPrivate: true }, + { id: "C_NEW/ID", name: "design-announcements", isMember: true, isPrivate: false }, + { id: "C_SHARED", name: "partner-shared", isMember: true, isPrivate: false }, + { id: "C_UNJOINED", name: "unjoined", isMember: false, isPrivate: false }, +]; +const bindings = [ + { provider: "slack", externalId: "C_HOME", teamId: team.id, kind: "primary" }, + { provider: "slack", externalId: "C_SOURCE", teamId: team.id, kind: "source" }, + { provider: "linear", externalId: "linear_team", teamId: team.id, kind: "source" }, +]; +let listedBindings = bindings.slice(0, 0); + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +beforeEach(() => { + vi.resetAllMocks(); + listedBindings = []; + vi.mocked(browserApiFetch).mockImplementation(async (url) => + Response.json(url === channelsKey ? { channels } : { bindings: listedBindings }) + ); +}); +afterEach(cleanup); + +describe("Team channels", () => { + it.each([ + undefined, + {}, + { canManageBindings: true }, + { ...team.capabilities, canManageBindings: false }, + ])( + "does not fetch bindings or enable controls without complete server capabilities: %s", + (capabilities) => { + render(, { wrapper }); + expect(browserApiFetch).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Binding kind" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Bind channel" })).toBeDisabled(); + expect(screen.getByText(/do not have permission to view or manage/i)).toBeInTheDocument(); + } + ); + + it("searches channel names, binds the selected ID as primary and refreshes", async () => { + listedBindings = [bindings[0]]; + render(, { wrapper }); + await screen.findByText("#home"); + expect(screen.getByRole("button", { name: "Bind channel" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: /Slack channel Select a channel/ })); + expect(screen.queryByRole("option", { name: "#unjoined" })).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: /#source.*Private channel/ })).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("Search channels..."), { + target: { value: "ANNOUNCE" }, + }); + expect(within(screen.getByRole("listbox")).getAllByRole("option")).toHaveLength(1); + fireEvent.click(screen.getByRole("option", { name: "#design-announcements" })); + fireEvent.change(screen.getByRole("combobox", { name: "Binding kind" }), { + target: { value: "primary" }, + }); + let finish!: (response: Response) => void; + const mutation = new Promise((resolve) => { + finish = resolve; + }); + vi.mocked(browserApiFetch).mockReturnValueOnce(mutation); + fireEvent.click(screen.getByRole("button", { name: "Bind channel" })); + expect( + screen.getByRole("button", { name: /Slack channel #design-announcements/ }) + ).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Binding kind" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Unbind Slack channel #home" })).toBeDisabled(); + await act(async () => { + listedBindings = [{ ...bindings[0], externalId: "C_NEW/ID", kind: "primary" }]; + finish(Response.json({ ok: true })); + }); + expect(await screen.findByText("#design-announcements")).toBeInTheDocument(); + expect(browserApiFetch).toHaveBeenCalledWith(channelsKey); + expect(browserApiFetch).toHaveBeenCalledWith(`${key}/slack/C_NEW%2FID`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "primary" }), + }); + await waitFor(() => + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeEnabled() + ); + }); + + it("unbinds Slack channels and reloads the authoritative list", async () => { + listedBindings = bindings; + render(, { wrapper }); + const unbind = await screen.findByRole("button", { name: "Unbind Slack channel #home" }); + const rows = within(screen.getByRole("list", { name: "Channel bindings" })); + expect(rows.getByText("#home")).toBeInTheDocument(); + expect(rows.getByText("Primary")).toBeInTheDocument(); + expect(rows.getAllByText("Source")).toHaveLength(2); + expect(rows.getByText("linear_team")).toBeInTheDocument(); + expect(rows.queryByRole("button", { name: /Unbind.*linear_team/ })).not.toBeInTheDocument(); + expect(browserApiFetch).toHaveBeenCalledWith(key); + listedBindings = []; + vi.mocked(browserApiFetch).mockResolvedValueOnce(new Response(null, { status: 204 })); + fireEvent.click(unbind); + await screen.findByText("No channel bindings yet."); + expect(browserApiFetch).toHaveBeenCalledWith(`${key}/slack/C_HOME`, { method: "DELETE" }); + expect(screen.queryByText("#home")).not.toBeInTheDocument(); + }); + + it("shows server refusal codes without clearing the draft or claiming success", async () => { + render(, { wrapper }); + await waitFor(() => + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeEnabled() + ); + fireEvent.click(screen.getByRole("button", { name: /Slack channel Select a channel/ })); + fireEvent.click(screen.getByRole("option", { name: "#partner-shared" })); + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json( + { error: "Channel is not joinable", code: "channel_not_joinable" }, + { status: 409 } + ) + ); + fireEvent.click(screen.getByRole("button", { name: "Bind channel" })); + expect(await screen.findByRole("alert")).toHaveTextContent("channel_not_joinable"); + expect(screen.getByRole("button", { name: /Slack channel #partner-shared/ })).toBeEnabled(); + expect(browserApiFetch).toHaveBeenCalledTimes(3); + }); + + it("withholds cached rows immediately when capabilities are revoked", async () => { + listedBindings = bindings; + const view = render(, { wrapper }); + await screen.findByText("#home"); + fireEvent.click(screen.getByRole("button", { name: /Slack channel Select a channel/ })); + vi.mocked(browserApiFetch).mockClear(); + view.rerender(); + expect(screen.queryByText("#home")).not.toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: /#source.*Private channel/ }) + ).not.toBeInTheDocument(); + expect(screen.queryByText("linear_team")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Bind channel" })).toBeDisabled(); + expect(browserApiFetch).not.toHaveBeenCalled(); + }); + + it("shows a load error rather than an empty list and supports retry", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Forbidden" }, { status: 403 }) + ); + render(, { wrapper }); + expect(await screen.findByRole("alert")).toHaveTextContent("Unable to load channel bindings."); + expect(screen.queryByText("No channel bindings yet.")).not.toBeInTheDocument(); + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ bindings: [] })); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("No channel bindings yet.")).toBeInTheDocument(); + }); + + it.each([ + { payload: { channels: [], error: "not_configured" }, status: 200 }, + { payload: { error: "Unavailable" }, status: 503 }, + { payload: { channels: "invalid" }, status: 200 }, + ])( + "disables binding on channel-list failure and supports retry: %j", + async ({ payload, status }) => { + vi.mocked(browserApiFetch).mockImplementation(async (url) => + url === channelsKey + ? Response.json(payload, { status }) + : Response.json({ bindings: listedBindings }) + ); + render(, { wrapper }); + expect(await screen.findByRole("alert")).toHaveTextContent("Unable to load Slack channels."); + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Bind channel" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Enter a channel ID instead" })); + fireEvent.change(screen.getByRole("textbox", { name: "Slack channel ID" }), { + target: { value: " C_NEW/ID " }, + }); + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ ok: true })); + fireEvent.click(screen.getByRole("button", { name: "Bind channel" })); + await waitFor(() => + expect(screen.getByRole("textbox", { name: "Slack channel ID" })).toHaveValue("") + ); + expect(browserApiFetch).toHaveBeenCalledWith(`${key}/slack/C_NEW%2FID`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "source" }), + }); + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ channels })); + fireEvent.click(screen.getByRole("button", { name: "Retry channels" })); + fireEvent.click(screen.getByRole("button", { name: "Choose from channels" })); + await waitFor(() => + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeEnabled() + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + } + ); + + it.each([ + ["Retry channels", { channels: [], error: "not_configured" }], + ["Refresh channels", { channels: [channels[4]] }], + ] as const)("%s never submits a manual binding draft", async (label, payload) => { + vi.mocked(browserApiFetch).mockImplementation(async (url) => + Response.json(url === channelsKey ? payload : { bindings: [] }) + ); + render(, { wrapper }); + const reload = await screen.findByRole("button", { name: label }); + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Enter a channel ID instead" })); + fireEvent.change(screen.getByRole("textbox", { name: "Slack channel ID" }), { + target: { value: "C_DRAFT" }, + }); + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ channels })); + fireEvent.click(reload); + await waitFor(() => + expect(screen.queryByRole("button", { name: label })).not.toBeInTheDocument() + ); + expect(screen.getByRole("textbox", { name: "Slack channel ID" })).toHaveValue("C_DRAFT"); + fireEvent.click(screen.getByRole("button", { name: "Choose from channels" })); + await waitFor(() => + expect(screen.getByRole("button", { name: /Slack channel Select a channel/ })).toBeEnabled() + ); + expect(vi.mocked(browserApiFetch).mock.calls.every(([, init]) => !init?.method)).toBe(true); + }); + + it.each([401, 403])( + "withholds cached channel names and all controls after a %s revalidation", + async (status) => { + listedBindings = bindings; + render(, { wrapper }); + await screen.findByText("#home"); + fireEvent.click(screen.getByRole("button", { name: /Slack channel Select a channel/ })); + expect(screen.getByRole("option", { name: /#source.*Private channel/ })).toBeInTheDocument(); + vi.mocked(browserApiFetch).mockImplementation(async (url) => + url === channelsKey + ? Response.json({ error: "Denied" }, { status }) + : Response.json({ bindings }) + ); + fireEvent.focus(window); + expect(await screen.findByRole("alert")).toHaveTextContent("Unable to load Slack channels."); + expect(screen.queryByText("#home")).not.toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: /#source.*Private channel/ }) + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Enter a channel ID instead" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Unbind Slack channel C_HOME" })).toBeDisabled(); + } + ); + + it("keeps the global listing endpoint for existing automation callers", async () => { + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({ channels })); + const { result } = renderHook(() => useSlackChannels(), { wrapper }); + await waitFor(() => expect(result.current.channels).toEqual(channels)); + expect(browserApiFetch).toHaveBeenCalledWith("/api/integrations/slack/channels"); + }); +}); diff --git a/packages/web/src/components/teams/team-channels.tsx b/packages/web/src/components/teams/team-channels.tsx new file mode 100644 index 0000000000..96a1b0e8e4 --- /dev/null +++ b/packages/web/src/components/teams/team-channels.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useId, useState } from "react"; +import useSWR from "swr"; +import { + teamChannelBindingsResponseSchema, + type TeamChannelBindingKind, +} from "@open-inspect/shared/types/team-channel-bindings"; +import { useTeamCapabilities } from "@/hooks/use-team-capabilities"; +import { useSlackChannels } from "@/hooks/use-slack-channels"; +import type { TeamResponse } from "@/hooks/use-teams"; +import { useAuthSession } from "@/lib/auth-session"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { Combobox } from "@/components/ui/combobox"; +import { ChevronDownIcon } from "@/components/ui/icons"; + +export function TeamChannels({ team }: { team: TeamResponse }) { + const { canManageBindings } = useTeamCapabilities(team); + const { data: session } = useAuthSession(); + const id = useId(); + const key = `/api/teams/${encodeURIComponent(team.id)}/channel-bindings` as const; + const { data, error, isLoading, mutate } = useSWR( + canManageBindings && session?.user ? [key, session.user.id] : null, + async () => { + const response = await browserApiFetch(key); + if (!response.ok) throw new Error(`Failed to load channel bindings (${response.status})`); + return teamChannelBindingsResponseSchema.parse(await response.json()); + } + ); + const [channelId, setChannelId] = useState(""); + const [manualEntry, setManualEntry] = useState(false); + const [kind, setKind] = useState("source"); + const [pending, setPending] = useState(false); + const [failure, setFailure] = useState(null); + const { + channels, + error: channelsError, + accessDenied: channelsAccessDenied, + loading: channelsLoading, + mutate: reloadChannels, + } = useSlackChannels(canManageBindings, team.id); + const disabled = + !canManageBindings || !session?.user || pending || isLoading || !!error || channelsAccessDenied; + const channelNames = new Map(channels.map((channel) => [channel.id, `#${channel.name}`])); + const channelOptions = channels + .filter((channel) => channel.isMember) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((channel) => ({ + value: channel.id, + label: `#${channel.name}`, + description: channel.isPrivate ? "Private channel" : undefined, + })); + const selectedChannel = channelOptions.find((channel) => channel.value === channelId); + const pickerDisabled = disabled || channelsLoading || !!channelsError || !channelOptions.length; + const bindDisabled = + disabled || (manualEntry ? !channelId.trim() : pickerDisabled || !selectedChannel); + + async function changeBinding(externalId: string, method: "PUT" | "DELETE") { + if (disabled || !externalId) return; + setPending(true); + setFailure(null); + try { + const response = await browserApiFetch(`${key}/slack/${encodeURIComponent(externalId)}`, { + method, + ...(method === "PUT" + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind }) } + : {}), + }); + if (!response.ok) { + const body = await response.json().catch(() => null); + const message = + typeof body?.error === "string" ? body.error : "Failed to update channel binding"; + throw new Error(typeof body?.code === "string" ? `${message} (${body.code})` : message); + } + if (method === "PUT") setChannelId(""); + await mutate(); + } catch (cause) { + setFailure(cause instanceof Error ? cause.message : "Failed to update channel binding"); + } finally { + setPending(false); + } + } + + return ( +

+

+ Channels +

+

+ Bind Slack channels to this team. Primary is the team's home channel; source channels + also route new sessions to the team. +

+
{ + event.preventDefault(); + if (!bindDisabled) void changeBinding(channelId.trim(), "PUT"); + }} + > +
+
+ + {manualEntry ? ( + setChannelId(event.target.value)} + placeholder="C0123456789" + autoComplete="off" + className="w-full rounded border border-border bg-background px-3 py-2 text-sm disabled:opacity-50" + /> + ) : ( + + + {selectedChannel?.label ?? + (channelsLoading ? "Loading channels..." : "Select a channel")} + + + + )} +
+
+ + +
+ +
+

+ Only channels the Slack bot has joined are listed. Invite it to a channel to add it here; + externally shared channels cannot be bound. Select a bound channel to change its kind. +

+ + {canManageBindings && + (channelsError ? ( + + Unable to load Slack channels.{" "} + + + ) : !channelsLoading && channelOptions.length === 0 ? ( +

+ No available channels. Invite the Slack bot to a channel, then{" "} + + . +

+ ) : null)} +
+ {!canManageBindings ? ( +

+ You do not have permission to view or manage channel bindings. +

+ ) : ( + <> + {failure && ( + + {failure} + + )} + {error ? ( + + Unable to load channel bindings.{" "} + + + ) : isLoading ? ( +

+ Loading channel bindings... +

+ ) : data?.bindings.length === 0 ? ( +

No channel bindings yet.

+ ) : ( +
    + {data?.bindings.map((binding) => ( +
  • +
    +

    + {binding.provider === "slack" + ? (channelNames.get(binding.externalId) ?? binding.externalId) + : binding.externalId} +

    +
    + {binding.provider === "slack" ? "Slack" : "Linear"} + {binding.kind === "primary" ? "Primary" : "Source"} +
    +
    + {binding.provider === "slack" ? ( + + ) : ( + Read-only + )} +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/packages/web/src/components/teams/team-page.tsx b/packages/web/src/components/teams/team-page.tsx index 3ab6b8926f..07a22f7671 100644 --- a/packages/web/src/components/teams/team-page.tsx +++ b/packages/web/src/components/teams/team-page.tsx @@ -26,6 +26,7 @@ import { TeamRepositories } from "./team-repositories"; import { TeamEnvironments } from "./team-environments"; import { TeamAutomations } from "./team-automations"; import { TeamSecrets } from "./team-secrets"; +import { TeamChannels } from "./team-channels"; type TeamTab = | "Overview" @@ -34,6 +35,7 @@ type TeamTab = | "Environments" | "Automations" | "Secrets" + | "Channels" | "Settings"; export function TeamPage({ slug }: { slug: string }) { @@ -120,6 +122,7 @@ function TeamContent({ : ["Members"]; if (canViewWork && canReadAutomations) tabs.push("Automations"); if (canViewWork && capabilities.canManageSecrets) tabs.push("Secrets"); + if (canViewWork) tabs.push("Channels"); if (canViewWork && (capabilities.canEditMetadata || capabilities.canArchive)) tabs.push("Settings"); const activeTab = tabs.includes(tab) ? tab : "Members"; @@ -170,6 +173,7 @@ function TeamContent({ {activeTab === "Secrets" && canViewWork && capabilities.canManageSecrets && ( )} + {activeTab === "Channels" && } {activeTab === "Settings" && } ); diff --git a/packages/web/src/components/teams/teams-pages.test.tsx b/packages/web/src/components/teams/teams-pages.test.tsx index 7a5e662daf..a5c603e89b 100644 --- a/packages/web/src/components/teams/teams-pages.test.tsx +++ b/packages/web/src/components/teams/teams-pages.test.tsx @@ -73,6 +73,7 @@ vi.mock("./team-secrets", () => ({ return

Team secrets editor for {props.teamId}

; }, })); +vi.mock("./team-channels", () => ({ TeamChannels: () =>

Team channel bindings

})); const team: TeamResponse = { id: "team_design", @@ -247,6 +248,7 @@ describe("Team page tabs", () => { const tabs = within(screen.getByRole("navigation", { name: "Team tabs" })); expect(tabs.getByRole("button", { name: "Members" })).toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Overview" })).not.toBeInTheDocument(); + expect(tabs.queryByRole("button", { name: "Channels" })).not.toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Activity" })).not.toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Repositories" })).not.toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Secrets" })).not.toBeInTheDocument(); @@ -264,6 +266,7 @@ describe("Team page tabs", () => { const tabs = within(screen.getByRole("navigation", { name: "Team tabs" })); expect(tabs.getByRole("button", { name: "Overview" })).toBeInTheDocument(); expect(tabs.getByRole("button", { name: "Members" })).toBeInTheDocument(); + expect(tabs.getByRole("button", { name: "Channels" })).toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Activity" })).not.toBeInTheDocument(); }); @@ -342,6 +345,7 @@ describe("Team page tabs", () => { view.rerender(); expect(screen.queryByText("Team session buckets")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Overview" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Channels" })).not.toBeInTheDocument(); expect(screen.getByText("Team member table")).toBeInTheDocument(); }); @@ -364,6 +368,7 @@ describe("Team page tabs", () => { render(); expect(screen.getByText("Team member table")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Overview" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Channels" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Repositories" })).not.toBeInTheDocument(); }); @@ -385,6 +390,7 @@ describe("Team page tabs", () => { render(); expect(screen.queryByText("Team session buckets")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Overview" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Channels" })).not.toBeInTheDocument(); }); it("unmounts private content when fresh team metadata reports an archive", () => { @@ -402,7 +408,12 @@ describe("Team page tabs", () => { (role) => { mocks.role = role === "lead" ? "member" : role; mocks.mine = role === "lead" ? [team] : []; - mocks.teams = [{ ...team, capabilities: { ...denied, canManageSecrets: true } }]; + mocks.teams = [ + { + ...team, + capabilities: { ...denied, canManageSecrets: true, canArchive: role === "lead" }, + }, + ]; render(); const tabs = within(screen.getByRole("navigation", { name: "Team tabs" })); @@ -413,7 +424,13 @@ describe("Team page tabs", () => { "Environments", "Automations", "Secrets", + "Channels", + ...(role === "lead" ? ["Settings"] : []), ]); + if (role === "lead") { + fireEvent.click(tabs.getByRole("button", { name: "Channels" })); + expect(screen.getByText("Team channel bindings")).toBeInTheDocument(); + } expect(screen.queryByText("Team secrets editor for team_design")).not.toBeInTheDocument(); fireEvent.click(tabs.getByRole("button", { name: "Secrets" })); expect(screen.getByText("Team secrets editor for team_design")).toBeInTheDocument(); diff --git a/packages/web/src/hooks/use-slack-channels.ts b/packages/web/src/hooks/use-slack-channels.ts index 6921d1127b..5885da0deb 100644 --- a/packages/web/src/hooks/use-slack-channels.ts +++ b/packages/web/src/hooks/use-slack-channels.ts @@ -1,26 +1,41 @@ import useSWR from "swr"; import { useAuthSession } from "@/lib/auth-session"; -import type { ControlPlaneSlackChannelsResponse } from "@open-inspect/shared/slack"; +import { controlPlaneSlackChannelsResponseSchema } from "@open-inspect/shared/slack"; +import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; /** - * Fetch the workspace's Slack channels for the automation channel picker. - * `error` is set (and `channels` empty) when listing is unavailable — no bot - * token, missing scopes, or a Slack API failure — so callers can fall back to - * manual channel-ID entry. + * Fetch the workspace's Slack channels, using team binding admission when scoped to a team. + * Listing failures withhold cached names. Callers may offer manual ID entry + * for availability failures, but not for an authoritative access denial. * * Pass `enabled: false` to skip the request entirely — e.g. when there is no * Slack channel to resolve — without violating the rules of hooks. */ -export function useSlackChannels(enabled = true) { +export function useSlackChannels(enabled = true, teamId?: string) { const { data: session } = useAuthSession(); + const key: BrowserApiPath = teamId + ? `/api/teams/${encodeURIComponent(teamId)}/slack-channels` + : "/api/integrations/slack/channels"; - const { data, isLoading } = useSWR( - enabled && session ? "/api/integrations/slack/channels" : null + const { data, error, isLoading, mutate } = useSWR( + enabled && session?.user ? [key, session.user.id] : null, + async () => { + const response = await browserApiFetch(key); + if (!response.ok) { + throw Object.assign(new Error(`Failed to load Slack channels (${response.status})`), { + status: response.status, + }); + } + return controlPlaneSlackChannelsResponseSchema.parse(await response.json()); + } ); + const listingError = error ? "fetch_failed" : data?.error; return { - channels: data?.channels ?? [], - error: data?.error, + channels: enabled && session?.user && !listingError ? (data?.channels ?? []) : [], + error: listingError, + accessDenied: error?.status === 401 || error?.status === 403, loading: isLoading, + mutate, }; } diff --git a/packages/web/src/lib/settings-proxy.test.ts b/packages/web/src/lib/settings-proxy.test.ts index 62048c14b5..b1d9cfa0a5 100644 --- a/packages/web/src/lib/settings-proxy.test.ts +++ b/packages/web/src/lib/settings-proxy.test.ts @@ -2,6 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { controlPlaneUserFetch } from "./control-plane"; import { SETTINGS_PROXY_MAX_BODY_BYTES, settingsProxy } from "./settings-proxy"; +import { GET as getChannelBindings } from "@/app/api/teams/[id]/channel-bindings/route"; +import { GET as getSlackChannels } from "@/app/api/teams/[id]/slack-channels/route"; +import { + DELETE as deleteChannelBinding, + PUT as putChannelBinding, +} from "@/app/api/teams/[id]/channel-bindings/slack/[channelId]/route"; vi.mock("./control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); @@ -158,4 +164,31 @@ describe("settingsProxy", () => { expect(response.status).toBe(500); await expect(response.json()).resolves.toEqual({ error: "Failed to fetch settings" }); }); + + it.each(["GET", "PUT", "DELETE", "channels"] as const)( + "exports the channel-binding %s proxy with encoded identifiers", + async (operation) => { + const method = operation === "channels" ? "GET" : operation; + const body = JSON.stringify({ kind: "source" }); + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ ok: true })); + const handler = { + GET: getChannelBindings, + PUT: putChannelBinding, + DELETE: deleteChannelBinding, + channels: getSlackChannels, + }[operation]; + await handler( + new NextRequest("http://localhost/api/teams/id/channel-bindings", { + method, + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, + ...(method === "PUT" ? { body } : {}), + }), + { params: Promise.resolve({ id: "team/id", channelId: "C/1" }) } + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + `/teams/team%2Fid/${operation === "channels" ? "slack-channels" : `channel-bindings${method === "GET" ? "" : "/slack/C%2F1"}`}`, + method === "GET" ? undefined : method === "PUT" ? { method, body } : { method } + ); + } + ); }); From 530d2d0c2bc56179cc40d624e10e4b6a7005c231 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:52:00 -0700 Subject: [PATCH 07/68] chore(deps): bump urllib3 from 2.7.0 to 2.8.0 in /packages/sandbox-images/locks/python-tools in the uv group across 1 directory (#2231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv group with 1 update in the /packages/sandbox-images/locks/python-tools directory: [urllib3](https://github.com/urllib3/urllib3). Updates `urllib3` from 2.7.0 to 2.8.0
Release notes

Sourced from urllib3's releases.

2.8.0

🚀 urllib3 is fundraising for HTTP/2 support

urllib3 is raising ~$40,000 USD to release HTTP/2 support and ensure long-term sustainable maintenance of the project. If your company or organization uses Python and would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and thousands of other projects please consider contributing financially to ensure HTTP/2 support is developed sustainably and maintained for the long-haul.

Thank you for your support.

Security

Fixed the following security issues:

  • The TLS configuration for HTTPS proxies could be ignored or overridden. (High severity, GHSA-8988-9cw3-xx77)
  • HTTPResponse.stream() and read_chunked() could buffer a chunk-size line of unbounded length in memory. (High severity, GHSA-vxq7-64xx-v4gw)
  • Chunked Deflate streaming could enter an infinite loop. (Medium severity, GHSA-gh4c-6fx4-qh6g)

[!IMPORTANT] urllib3 2.8.0 fixes HTTPS proxy TLS configuration being ignored or overridden by destination settings. Configurations relying on that behavior may require changes.

Configure proxy CA certificates and client certificates in proxy_ssl_context, and proxy identity checks with proxy_assert_hostname or proxy_assert_fingerprint. Destination client certificates and identity overrides no longer apply to HTTPS forwarding proxy connections.

[!NOTE] CVE IDs had not yet been assigned to these advisories at the time of release due to a backlog at GitHub's CNA.

Deprecations & Removals

  • Deprecated using an empty collection as the Retry option allowed_methods to retry any verb. (#5044)

Features

  • Added Url.auth_decoded and Url.auth_decoded_joined convenience properties to the result of parse_url(). (#4945)
  • Added basic_auth_encoding and proxy_basic_auth_encoding parameters to urllib3.util.make_headers(). (#5092)

Bugfixes

  • Fixed response header handling to replace obsolete folded header lines (obs-fold) with spaces in accordance with RFC 9112, preventing raw CRLF sequences from appearing in header values such as Set-Cookie. (#1362)

  • Fixed usage of proxy_ssl_context with ProxyManager when use_forwarding_for_https=True. Passing ssl_context instead of proxy_ssl_context for HTTPS proxies in this configuration now emits a FutureWarning and will raise an error in v3.0. (#2577)

  • Changed behavior of the default ConnectionPool.pool initialization. LifoQueue is now resolved from the queue module after the ConnectionPool is instantiated instead of using the default cached QueueCls class property. This is done because sometimes the queue.LifoQueue is monkey-patched late in the program, such as by gevent. (#3289)

  • Raised UnrewindableBodyError instead of ValueError when retrying a request whose body had tell() but not seek(). (#3779)

  • Decoded percent-encoded SOCKS proxy credentials before authenticating with the proxy server. (#3785)

  • Fixed HTTPResponse.drain_conn() to discard unread response data in 64 KiB chunks (same as the default amt when doing HTTPResponse.stream(...)). (#5019)

  • Fixed is_ipaddress() to detect non-standard IPv4 forms accepted by socket.connect, such as hex (0x7f000001), octal (0177.0.0.1), and decimal integers (2130706433), ensuring SSL certificate verification uses the correct mode for these addresses. (#5029)

  • Fixed HTTPConnectionPool.urlopen raising a misleading FullPoolError instead of ValueError when called with an invalid timeout argument on a pool created with block=True. (#5059)

  • Fixed port-zero handling to preserve explicit :0 values instead of substituting the default ports 80 or 443 in URL parsing, pool selection, proxy configuration, connection_from_url(), and HTTP/2 request authority. (#5071, #5101)

  • Fixed a bug where PoolManager passed the assert_hostname and assert_fingerprint parameters to HTTP connection pools. (#5077)

  • Fixed HTTPConnectionPool.urlopen() and HTTP proxy forwarding to strip URL fragments from absolute request targets before sending requests. (#5079)

  • Added safeguards to the proxy tunneling code to prevent potential security issues when handling invalid characters in the proxy host and HTTP headers. This change affects users of Python 3.10, Python 3.11, and Python 3.12 when the standard library does not contain the fix; those on newer Python versions should upgrade to 3.13.14+ or 3.14.5+ to get the same security fixes. (#5091)

  • Fixed HTTPSConnection.connect() overriding ProxyConfig.ssl_context's certificate policy and proxy identity checks with the target connection's TLS settings when forwarding through an HTTPS proxy.

    HTTPSConnection no longer applies target SNI, assertions, or client credentials to forwarding proxy handshakes and continues to use its ssl_context as a fallback when an HTTPS proxy forwards an HTTP target. (#5093)

  • Fixed URL parsing to more strictly enforce RFC 3986 host syntax, rejecting invalid host input such as raw spaces and control characters, malformed percent-encodings, and percent-encoded control characters in HTTP(S) hosts and IPv6 zone identifiers, including proxy CONNECT tunnel targets. Host normalization now also follows RFC 3986 normalization rules for percent-encoded octets by decoding percent-encoded unreserved characters and uppercasing the hexadecimal digits of retained percent-encoded octets. (#5095)

... (truncated)

Changelog

Sourced from urllib3's changelog.

2.8.0 (2026-09-15)

Security

Fixed the following security issues:

  • The TLS configuration for HTTPS proxies could be ignored or overridden. (High severity, GHSA-8988-9cw3-xx77 <https://github.com/urllib3/urllib3/security/advisories/GHSA-8988-9cw3-xx77>__)
  • HTTPResponse.stream() and read_chunked() could buffer a chunk-size line of unbounded length in memory. (High severity, GHSA-vxq7-64xx-v4gw <https://github.com/urllib3/urllib3/security/advisories/GHSA-vxq7-64xx-v4gw>__)
  • Chunked Deflate streaming could enter an infinite loop. (Medium severity, GHSA-gh4c-6fx4-qh6g <https://github.com/urllib3/urllib3/security/advisories/GHSA-gh4c-6fx4-qh6g>__)

.. caution::

urllib3 2.8.0 fixes HTTPS proxy TLS configuration being
ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.

Configure proxy CA certificates and client certificates in proxy_ssl_context, and proxy identity checks with proxy_assert_hostname or proxy_assert_fingerprint. Destination client certificates and identity overrides no longer apply to HTTPS forwarding proxy connections.

Deprecations & Removals

  • Deprecated using an empty collection as the Retry option allowed_methods to retry any verb. ([#5044](https://github.com/urllib3/urllib3/issues/5044) <https://github.com/urllib3/urllib3/issues/5044>__)

Features

  • Added Url.auth_decoded and Url.auth_decoded_joined convenience properties to the result of parse_url(). ([#4945](https://github.com/urllib3/urllib3/issues/4945) <https://github.com/urllib3/urllib3/issues/4945>__)
  • Added basic_auth_encoding and proxy_basic_auth_encoding parameters to urllib3.util.make_headers(). ([#5092](https://github.com/urllib3/urllib3/issues/5092) <https://github.com/urllib3/urllib3/issues/5092>__)

Bugfixes

... (truncated)

Commits
  • b1d30ab Release 2.8.0
  • 9016d7e Skip test_read_chunked_with_trailing_data_does_not_hang for brotlicffi (#5258)
  • 9101f58 Fix nox -s docs warning (#5256)
  • cd770b0 Merge commit from fork
  • ea2ad7b Merge commit from fork
  • 0716e31 Fix loading unencrypted client keys with a password in pyOpenSSL (#5255)
  • 43c68c8 Test pickling of InvalidChunkLength (#5247)
  • 308b279 Share security policy between GitHub and Read the Docs (#5253)
  • 53fa073 Add policy on duplicate pull requests (#5252)
  • 5f2a6a8 Assert on the ALPN extension in test_tunnel_sets_http_11_alpn (#5232)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=urllib3&package-manager=uv&previous-version=2.7.0&new-version=2.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ColeMurray/background-agents/network/alerts).
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> --- packages/sandbox-images/locks/python-tools.txt | 6 +++--- packages/sandbox-images/locks/python-tools/uv.lock | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sandbox-images/locks/python-tools.txt b/packages/sandbox-images/locks/python-tools.txt index e5c6eb4134..aac4e148f1 100644 --- a/packages/sandbox-images/locks/python-tools.txt +++ b/packages/sandbox-images/locks/python-tools.txt @@ -343,9 +343,9 @@ trove-classifiers==2026.6.1.19 \ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 -urllib3==2.7.0 \ - --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ - --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 websockify==0.13.0 \ --hash=sha256:9969731116653226c4c499a8a50712c8f3f7c105c615ead7ebc6ae781f0ba954 \ --hash=sha256:ac497a8dafc7f51d28ca989f01cdf5f8add663a497d425a56b159dcb8d6a314c diff --git a/packages/sandbox-images/locks/python-tools/uv.lock b/packages/sandbox-images/locks/python-tools/uv.lock index ddfcf92ad5..b10f6b319c 100644 --- a/packages/sandbox-images/locks/python-tools/uv.lock +++ b/packages/sandbox-images/locks/python-tools/uv.lock @@ -482,11 +482,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] From 84a6384bec4047b1fbfae08e9309c5064c550582 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 18:57:22 -0700 Subject: [PATCH 08/68] feat(web): autosave session visibility and child-session scope (#2232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Removes the Save button from the session visibility control. Each control now saves on its own: - **Visibility dropdown** — choosing a different visibility saves right away. If child sessions are included and the new visibility isn't private, the existing confirmation dialog appears first. Cancelling leaves the visibility unchanged. - **Include child sessions checkbox** - **Checking it** applies the session's current visibility to its child sessions (the server already supports a same-visibility cascade). This also asks for confirmation when the visibility isn't private. - **Unchecking it** saves nothing, because there's no persisted setting to revert. It only means later visibility changes affect this session alone. - An "Updating..." label replaces the button label while a save is in flight, and all controls are disabled until it finishes. - If a save fails, the dropdown goes back to the previous visibility, unless "Retry without child sessions" is offered; then it keeps the failed choice so the retry can use it. - The team owner-membership warning also shows while the confirmation dialog is open for a change to team visibility. ## Testing - `vitest run src/components/session-controls.test.tsx` (27 passed), rewritten for autosave behavior - `tsc --noEmit`, eslint, prettier --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/ad4b14f9cdc0e17614c06cdb8b2e82af)* ## Summary by CodeRabbit * **Session Visibility** * Visibility changes save immediately when selected; a separate Save action is no longer needed. * Applying visibility changes to child sessions requires confirmation. * Controls are unavailable during refreshes, when permission to change visibility is unavailable, or when the required owner is missing. * After a retryable failure, retry the selected visibility change without applying it to child sessions. Other failed changes revert to their previous settings. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../src/components/session-controls.test.tsx | 255 ++++++++++-------- .../components/session-right-sidebar.test.tsx | 4 +- .../components/session-visibility-control.tsx | 110 +++++--- 3 files changed, 219 insertions(+), 150 deletions(-) diff --git a/packages/web/src/components/session-controls.test.tsx b/packages/web/src/components/session-controls.test.tsx index 141ffc120c..c6ae255b04 100644 --- a/packages/web/src/components/session-controls.test.tsx +++ b/packages/web/src/components/session-controls.test.tsx @@ -93,32 +93,140 @@ beforeEach(() => { afterEach(cleanup); describe("SessionVisibilityControl", () => { - it("uses the shared dropdown and saves the selected visibility and child-session scope", async () => { + const childrenBox = () => screen.getByRole("checkbox", { name: "Include child sessions" }); + const confirmButton = () => + within(screen.getByRole("alertdialog")).getByRole("button", { name: "Change visibility" }); + + it("saves immediately when a visibility is selected, without a save button", async () => { render(); expect(screen.getByRole("combobox", { name: "Visibility" }).tagName).toBe("BUTTON"); - expect(screen.getByRole("checkbox", { name: "Include child sessions" })).toBeChecked(); + expect(childrenBox()).toBeChecked(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); await selectVisibility("Private"); - expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Private"); - fireEvent.click(screen.getByRole("checkbox", { name: "Include child sessions" })); - expect(browserApiFetch).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); expectMutation("/api/sessions/session%2Fid/visibility", { visibility: "private", - includeChildren: false, + includeChildren: true, }); expect(mocks.mutate).toHaveBeenCalledWith(expect.any(Function)); }); - it("loads membership for the owner team and warns before selecting team visibility", async () => { - mocks.members = []; + it("scopes later changes to this session after unchecking children without saving", async () => { + render(); + fireEvent.click(childrenBox()); + expect(childrenBox()).not.toBeChecked(); + expect(browserApiFetch).not.toHaveBeenCalled(); + await selectVisibility("Workspace"); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); + expectMutation("/api/sessions/session%2Fid/visibility", { + visibility: "workspace", + includeChildren: false, + }); + }); + + it.each(["workspace", "team"] as const)( + "confirms before checking children applies %s visibility to them", + async (visibility) => { + render( + + ); + fireEvent.click(childrenBox()); + fireEvent.click(childrenBox()); + expect(browserApiFetch).not.toHaveBeenCalled(); + fireEvent.click(confirmButton()); + await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); + expectMutation("/api/sessions/session%2Fid/visibility", { + visibility, + includeChildren: true, + }); + expect(childrenBox()).toBeChecked(); + } + ); + + it("applies private visibility to children immediately when checked", async () => { render(); + fireEvent.click(childrenBox()); + fireEvent.click(childrenBox()); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); + expectMutation("/api/sessions/session%2Fid/visibility", { + visibility: "private", + includeChildren: true, + }); + }); + + it("does not save when the current visibility is reselected", async () => { + render(); await selectVisibility("Team"); - expect(mocks.useMembers).toHaveBeenCalledWith("source"); - expect(screen.getByText(/owner is not a member.*may lose access/i)).toBeInTheDocument(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(browserApiFetch).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + "warns about owner membership inside the team confirmation when includeChildren is %s", + async (includeChildren) => { + mocks.members = []; + render(); + if (!includeChildren) fireEvent.click(childrenBox()); + await selectVisibility("Team"); + expect(mocks.useMembers).toHaveBeenCalledWith("source"); + expect( + within(screen.getByRole("alertdialog")).getByText(/owner is not a member.*may lose access/i) + ).toBeInTheDocument(); + expect(browserApiFetch).not.toHaveBeenCalled(); + fireEvent.click(confirmButton()); + await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); + expectMutation("/api/sessions/session%2Fid/visibility", { + visibility: "team", + includeChildren, + }); + } + ); + + it("reverts a rejected session-only write without offering a retry", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json( + { error: "Forbidden", code: "session_action_denied", reason_code: "not_owner" }, + { status: 403 } + ) + ); + render(); + fireEvent.click(childrenBox()); + await selectVisibility("Private"); + expect(await screen.findByRole("alert")).toHaveTextContent("not_owner"); + expect(screen.queryByRole("button", { name: "Retry without child sessions" })).toBeNull(); + expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Team"); + }); + + it("discards a retryable failed target when children are unchecked", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json( + { error: "Descendant inaccessible", code: "descendant_inaccessible" }, + { status: 409 } + ) + ); + render(); + await selectVisibility("Private"); + await screen.findByRole("button", { name: "Retry without child sessions" }); + fireEvent.click(childrenBox()); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Team"); }); - it("disables unavailable team/private options and all mutations without capabilities", async () => { + it("restores the checkbox when a checkbox-triggered cascade fails", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Session owner required", code: "owner_required" }, { status: 400 }) + ); + render(); + fireEvent.click(childrenBox()); + fireEvent.click(childrenBox()); + expect(await screen.findByRole("alert")).toHaveTextContent("owner_required"); + expect(childrenBox()).not.toBeChecked(); + }); + + it("disables unavailable team/private options and all controls without capabilities", async () => { const { rerender } = render( { /> ); expect(screen.getByRole("combobox", { name: "Visibility" })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - expect(browserApiFetch).not.toHaveBeenCalled(); + expect(childrenBox()).toBeDisabled(); rerender( { ); }); - it.each(["workspace", "team", "private"] as const)( - "disables applying unchanged %s visibility even when children are included", - (visibility) => { - render( - - ); - const apply = screen.getByRole("button", { name: "Save" }); - expect(apply).toBeDisabled(); - fireEvent.click(apply); - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); - expect(browserApiFetch).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("checkbox", { name: "Include child sessions" })); - expect(apply).toBeDisabled(); - fireEvent.click(apply); - expect(browserApiFetch).not.toHaveBeenCalled(); - } - ); - - it("disables apply when the selection returns to the current visibility or a refresh matches it", async () => { - const { rerender } = render(); - const apply = screen.getByRole("button", { name: "Save" }); - await selectVisibility("Workspace"); - expect(apply).toBeEnabled(); - await selectVisibility("Team"); - expect(apply).toBeDisabled(); - await selectVisibility("Workspace"); - rerender( - - ); - expect(apply).toBeDisabled(); - fireEvent.click(apply); - expect(browserApiFetch).not.toHaveBeenCalled(); - }); - it.each([ ["private", "workspace"], ["private", "team"], @@ -196,23 +269,21 @@ describe("SessionVisibilityControl", () => { render( ); - await selectVisibility(target.charAt(0).toUpperCase() + target.slice(1)); - fireEvent.click(screen.getByRole("button", { name: "Save" })); + const label = target.charAt(0).toUpperCase() + target.slice(1); + await selectVisibility(label); const confirmation = within(screen.getByRole("alertdialog")); expect( confirmation.getByText( new RegExp(`any private child sessions will change to ${target} visibility`, "i") ) ).toBeInTheDocument(); - expect(browserApiFetch).not.toHaveBeenCalled(); fireEvent.click(confirmation.getByRole("button", { name: "Cancel" })); expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Visibility" })).not.toHaveTextContent(label); expect(browserApiFetch).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - fireEvent.click( - within(screen.getByRole("alertdialog")).getByRole("button", { name: "Change visibility" }) - ); + await selectVisibility(label); + fireEvent.click(confirmButton()); await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); expect(browserApiFetch).toHaveBeenCalledOnce(); expectMutation("/api/sessions/session%2Fid/visibility", { @@ -223,39 +294,12 @@ describe("SessionVisibilityControl", () => { } ); - it.each([ - ["team", "private", true], - ["team", "workspace", false], - ["workspace", "team", false], - ] as const)( - "changes %s visibility to %s without confirmation when includeChildren is %s", - async (visibility, target, includeChildren) => { - render( - - ); - await selectVisibility(target.charAt(0).toUpperCase() + target.slice(1)); - if (!includeChildren) - fireEvent.click(screen.getByRole("checkbox", { name: "Include child sessions" })); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); - await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); - expectMutation("/api/sessions/session%2Fid/visibility", { - visibility: target, - includeChildren, - }); - } - ); - it("guards confirmation when the visibility capability is revoked", async () => { const { rerender } = render(); await selectVisibility("Workspace"); - fireEvent.click(screen.getByRole("button", { name: "Save" })); rerender(); - const confirm = within(screen.getByRole("alertdialog")).getByRole("button", { - name: "Change visibility", - }); - expect(confirm).toBeDisabled(); - fireEvent.click(confirm); + expect(confirmButton()).toBeDisabled(); + fireEvent.click(confirmButton()); expect(browserApiFetch).not.toHaveBeenCalled(); }); @@ -268,13 +312,10 @@ describe("SessionVisibilityControl", () => { ); render(); await selectVisibility("Workspace"); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - expect(browserApiFetch).not.toHaveBeenCalled(); - fireEvent.click( - within(screen.getByRole("alertdialog")).getByRole("button", { name: "Change visibility" }) - ); + fireEvent.click(confirmButton()); const retry = await screen.findByRole("button", { name: "Retry without child sessions" }); expect(screen.getByRole("alert")).toHaveTextContent("not_owner"); + expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Workspace"); expect(browserApiFetch).toHaveBeenCalledOnce(); fireEvent.click(retry); expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); @@ -284,7 +325,7 @@ describe("SessionVisibilityControl", () => { visibility: "workspace", includeChildren: false, }); - expect(screen.getByRole("checkbox", { name: "Include child sessions" })).not.toBeChecked(); + expect(childrenBox()).not.toBeChecked(); }); it("guards a retry without children when the visibility capability is revoked", async () => { @@ -296,7 +337,6 @@ describe("SessionVisibilityControl", () => { ); const { rerender } = render(); await selectVisibility("Private"); - fireEvent.click(screen.getByRole("button", { name: "Save" })); const retry = await screen.findByRole("button", { name: "Retry without child sessions" }); rerender(); expect(retry).toBeDisabled(); @@ -305,16 +345,19 @@ describe("SessionVisibilityControl", () => { expect(mocks.updated).not.toHaveBeenCalled(); }); - it.each(["owner_required", "team_required"])("preserves server %s errors", async (code) => { - vi.mocked(browserApiFetch).mockResolvedValue( - Response.json({ error: "Invalid visibility", code }, { status: 400 }) - ); - render(); - await selectVisibility("Private"); - fireEvent.click(screen.getByRole("button", { name: "Save" })); - expect(await screen.findByRole("alert")).toHaveTextContent(code); - expect(screen.queryByRole("button", { name: "Retry without child sessions" })).toBeNull(); - }); + it.each(["owner_required", "team_required"])( + "preserves server %s errors and reverts the selection", + async (code) => { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ error: "Invalid visibility", code }, { status: 400 }) + ); + render(); + await selectVisibility("Private"); + expect(await screen.findByRole("alert")).toHaveTextContent(code); + expect(screen.queryByRole("button", { name: "Retry without child sessions" })).toBeNull(); + expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Team"); + } + ); it("disables all controls until the updated snapshot finishes refreshing", async () => { let finishRefresh!: () => void; @@ -326,16 +369,14 @@ describe("SessionVisibilityControl", () => { ); render(); await selectVisibility("Private"); - fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(mocks.updated).toHaveBeenCalledOnce()); expect(screen.getByRole("combobox", { name: "Visibility" })).toBeDisabled(); - expect(screen.getByRole("checkbox", { name: "Include child sessions" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Updating..." })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Updating..." })); - expect(browserApiFetch).toHaveBeenCalledOnce(); + expect(childrenBox()).toBeDisabled(); + expect(screen.getByText("Updating...")).toBeInTheDocument(); finishRefresh(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Visibility" })).toBeEnabled()); - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(screen.queryByText("Updating...")).toBeNull(); + expect(browserApiFetch).toHaveBeenCalledOnce(); }); }); diff --git a/packages/web/src/components/session-right-sidebar.test.tsx b/packages/web/src/components/session-right-sidebar.test.tsx index 4f76d1461f..ad247627a7 100644 --- a/packages/web/src/components/session-right-sidebar.test.tsx +++ b/packages/web/src/components/session-right-sidebar.test.tsx @@ -190,7 +190,7 @@ describe("SessionRightSidebar", () => { selectTab("Info"); expect(screen.getByRole("link", { name: "Design" })).toHaveAttribute("href", "/teams/design"); expect(screen.getByText("private")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Visibility" })).toBeInTheDocument(); expect(screen.getByText("Unnamed user \u00b7 orator")).toBeInTheDocument(); expect(screen.queryByText("user_collaborator")).not.toBeInTheDocument(); rerender(); @@ -199,7 +199,7 @@ describe("SessionRightSidebar", () => { rerender( ); - expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Visibility" })).not.toBeInTheDocument(); expect(screen.queryByText("Unnamed user \u00b7 orator")).not.toBeInTheDocument(); }); diff --git a/packages/web/src/components/session-visibility-control.tsx b/packages/web/src/components/session-visibility-control.tsx index e852c6dd58..679ab4a421 100644 --- a/packages/web/src/components/session-visibility-control.tsx +++ b/packages/web/src/components/session-visibility-control.tsx @@ -64,43 +64,62 @@ export function SessionVisibilityControl({ }: SessionVisibilityControlProps) { const { mutate, cache } = useSWRConfig(); const id = useId(); - // Untouched controls follow refreshed snapshots; dirty selections remain until applied. + // Holds the in-flight or failed selection; otherwise the control follows refreshed snapshots. const [selection, setSelection] = useState(null); const selected = selection ?? visibility; const [includeChildren, setIncludeChildren] = useState(true); - const [confirmChildren, setConfirmChildren] = useState(false); + const [confirm, setConfirm] = useState<{ + target: SessionVisibility; + children: boolean; + } | null>(null); const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); - const disabled = - !canChangeVisibility || - pending || - selected === visibility || - (selected === "team" && !ownerTeamId) || - (selected === "private" && !ownerUserId); + const editable = canChangeVisibility && !pending; - async function changeVisibility(children: boolean) { - if (disabled) return; + function isAllowed(target: SessionVisibility) { + return (target !== "team" || !!ownerTeamId) && (target !== "private" || !!ownerUserId); + } + + async function changeVisibility(target: SessionVisibility, children: boolean) { + if (!editable || !isAllowed(target)) return; + const previousChildren = includeChildren; setPending(true); setFailure(null); + setSelection(target); setIncludeChildren(children); try { await updateSessionScope( `/api/sessions/${encodeURIComponent(sessionId)}/visibility`, { method: "PUT", - body: { visibility: selected, includeChildren: children }, + body: { visibility: target, includeChildren: children }, }, onUpdated, { mutate, cache } ); setSelection(null); } catch (cause) { + // Keep the failed attempt only when "Retry without child sessions" is offered. + if (!(children && cause instanceof SessionScopeError && cause.canRetryWithoutChildren)) { + setSelection(null); + setIncludeChildren(previousChildren); + } setFailure(cause instanceof Error ? cause : new Error("Failed to change visibility")); } finally { setPending(false); } } + /** + * Cascading a non-private visibility can expose private children, and team visibility can + * revoke the owner's access, so both require confirmation. + */ + function requestChange(target: SessionVisibility, children: boolean) { + setFailure(null); + if ((children && target !== "private") || target === "team") setConfirm({ target, children }); + else void changeVisibility(target, children); + } + return (
@@ -109,14 +128,11 @@ export function SessionVisibilityControl({ - + @@ -341,6 +346,7 @@ function RepoOverrideRow({ {autoReviewMode === "override" && ( )}
+
diff --git a/packages/web/src/lib/automation-invocation-status.ts b/packages/web/src/lib/automation-invocation-status.ts index 2f7e057b97..a6d4259b5d 100644 --- a/packages/web/src/lib/automation-invocation-status.ts +++ b/packages/web/src/lib/automation-invocation-status.ts @@ -10,6 +10,7 @@ export const AUTOMATION_INVOCATION_STATUS: Record< running: { label: "Running", tone: "info" }, completed: { label: "Completed", tone: "success" }, failed: { label: "Failed", tone: "danger" }, + unauthorized: { label: "Unauthorized", tone: "danger" }, partial_failed: { label: "Partial failure", tone: "warning" }, skipped: { label: "Skipped", tone: "warning" }, }; From 006e7c9439ef7d69f24cfe5d4ca5673183e6aaad Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Sat, 3 Oct 2026 09:03:04 +0400 Subject: [PATCH 12/68] fix(web): hide loaded automation list after 403/404 refetch (#2219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Found in review. A mounted automation list keeps showing previously loaded automations after the list refetch is denied. Example: a user is viewing a team's automations, leaves the team (or loses access to it), and the next revalidation of `/api/automations?teamId=…` returns 403. `useAutomations` reports the 403 as `error`, but `automations` still contains the team's automation names, so the list and its row actions stay on screen. The detail and invocation hooks in the same file already hide cached data on 401/403/404 via `isTerminalAutomationError`; the list hook only applied that guard to its own `retained` copy. ## Cause `packages/web/src/hooks/use-automations.ts:71-75`: ```ts const pages = data ?? (retained && retained.key === listKey && !isTerminalAutomationError(error) ? retained.pages : undefined); ``` SWR keeps `data` when a revalidation throws, so after a terminal refetch failure `data` is still the last successful pages and wins the `??` before the terminal-error check is reached. ## Fix Check `isTerminalAutomationError(error)` before either cached source. Transient failures (network, 5xx, contract errors) still keep the last loaded pages, as the existing comment describes. ## Verified - New test `after a %i refetch shows %j for a loaded list` (403 / 404 / 500) in `use-automations.test.tsx`: the 403 and 404 cases fail on `main` (`expected [ { id: 'auto-2', … } ] to deeply equal []`, with `error` already set to the 403); all pass with the fix. The 500 case pins that transient failures still keep the list. - `npm run typecheck` exits 0. - `packages/web` vitest: 270 files / 2778 tests pass. ## Summary by CodeRabbit * **Bug Fixes** * Automation lists no longer display previously loaded results after an authentication or access error (401 or 403) or when the requested resource is not found (404). * Previously loaded automations remain visible if a temporary server error occurs (500), so the list isn’t unnecessarily cleared. --- .../web/src/hooks/use-automations.test.tsx | 25 +++++++++++++++++++ packages/web/src/hooks/use-automations.ts | 8 +++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/web/src/hooks/use-automations.test.tsx b/packages/web/src/hooks/use-automations.test.tsx index 881dcd01cb..d789c30cbe 100644 --- a/packages/web/src/hooks/use-automations.test.tsx +++ b/packages/web/src/hooks/use-automations.test.tsx @@ -5,6 +5,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { SWRConfig } from "swr"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AutomationListItem, ListAutomationsResponse } from "@open-inspect/shared"; +import { SwrFetchError } from "@/lib/swr-fetch-error"; import { useAutomations } from "./use-automations"; vi.mock("@/lib/auth-session", () => ({ @@ -236,4 +237,28 @@ describe("useAutomations", () => { fetcher.mock.calls.filter(([path]) => String(path).includes("cursor=")).map(([path]) => path) ).toEqual(["/api/automations?limit=25&cursor=next"]); }); + + it.each([ + [403, []], + [404, []], + [500, [firstAutomation]], + ])("after a %i refetch shows %j for a loaded list", async (status, expected) => { + let failure: SwrFetchError | null = null; + const fetcher = vi.fn(async (): Promise => { + if (failure) throw failure; + return { automations: [firstAutomation], hasMore: false, nextCursor: null }; + }); + const { result } = renderHook(() => useAutomations("", "team-1"), { + wrapper: wrapper(fetcher), + }); + await waitFor(() => expect(result.current.automations).toEqual([firstAutomation])); + + failure = new SwrFetchError(status); + await act(async () => { + await result.current.mutate(); + }); + + await waitFor(() => expect(result.current.error).toBe(failure)); + expect(result.current.automations).toEqual(expected); + }); }); diff --git a/packages/web/src/hooks/use-automations.ts b/packages/web/src/hooks/use-automations.ts index 04abd5dcca..6dad532aa9 100644 --- a/packages/web/src/hooks/use-automations.ts +++ b/packages/web/src/hooks/use-automations.ts @@ -68,11 +68,9 @@ export function useAutomations(nameSearch: string, teamId?: string | null) { if (data && listKey && (retained?.key !== listKey || retained.pages !== data)) { setRetained({ key: listKey, pages: data }); } - const pages = - data ?? - (retained && retained.key === listKey && !isTerminalAutomationError(error) - ? retained.pages - : undefined); + const pages = isTerminalAutomationError(error) + ? undefined + : (data ?? (retained && retained.key === listKey ? retained.pages : undefined)); const loadedPages = pages?.filter((page) => page !== undefined) ?? []; const automations = loadedPages.flatMap((page) => page.automations); From 87756a14d6b796d550b068d33f038339b861a1fb Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Sat, 3 Oct 2026 09:17:50 +0400 Subject: [PATCH 13/68] fix(web): omit unchanged repositories when editing an environment (#2221) ## Problem When editing, the environment form always submits the current repository list, even when it is unchanged. The update route treats a present `repositories` field as a replacement and always re-resolves and re-authorizes it. Edits the API accepts with the field omitted are therefore rejected from the UI: - **Revoked team grant.** A team environment's grant for one of its repositories is revoked. Disabling prebuilds is how #2205 says to handle this ("disabling prebuilds on an environment with revoked grants still works"). From the form it returns `409 target_team_missing_grant`, and so does renaming or re-describing an environment whose prebuilds are already off. `PUT /environments/:id` with `{ "prebuildEnabled": false }` (or a name/description change while prebuilds stay off) succeeds. - **No `repositories.use`.** A team lead who manages the environment but lacks `repositories.use` gets `403 permission_required` on every save from the form. The route lets that user edit metadata while leaving repositories alone (`allows granted unchanged members without repositories.use or replacing them` in `environments-target-denied.test.ts`). ## Cause `packages/web/src/components/settings/environment-form.tsx:129-143` builds `repositories` from the current selection on every submit. In `packages/control-plane/src/routes/environments.ts:328-344`, a present `repositories` field goes through `resolveAuthorizedRepositories` (the `repositories.use` preflight plus the team grant check) whatever the prebuild state. An omitted field only re-checks stored repositories when prebuilds stay enabled on a team environment. ## Fix In edit mode, the form leaves out `repositories` when the selection is unchanged: same repositories, same order (the order sets the primary repository) and same base branches. Names compare as lowercase full names, matching the selection keys. Create submissions and changed selections are unaffected. `EnvironmentFormValues.repositories` becomes optional to match `updateEnvironmentInputSchema`. ## Verified - New `environment-form.test.tsx` case: renaming a team environment without touching its repositories submits `{ name, description, prebuildEnabled }` with no `repositories`. The stored owner is mixed-case. Before the fix it fails because the submitted values include the unchanged `repositories` array. After the fix it passes. - Edit-mode changes still send the full selection. A new case changes only one repository's base branch (nested `group/subgroup` owner) and expects both repositories with the new branch. With the branch comparison replaced by `true` it fails, and it passes as written. The existing reorder case still sends the new order. Two edit-mode tests asserted the unchanged list as a side effect. The ownership test drops that assertion, and the nested-namespace parsing test now submits through create mode, where the list is always sent. - Control-plane behaviour, checked with the route test harness (not part of this PR). Prebuild-disabled team environment with no grant: `{ name, description, prebuildEnabled: false }` returns 200, and the same body plus the unchanged `repositories` returns 409 `target_team_missing_grant`. With only `environments.manage`, the resent list returns 403 `permission_required`. - `npm run typecheck` passes. The `@open-inspect/web` vitest suite passes. --- .../settings/environment-form.test.tsx | 70 +++++++++++++++++-- .../components/settings/environment-form.tsx | 34 ++++++--- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/packages/web/src/components/settings/environment-form.test.tsx b/packages/web/src/components/settings/environment-form.test.tsx index 527dc0abec..418f7aaeb5 100644 --- a/packages/web/src/components/settings/environment-form.test.tsx +++ b/packages/web/src/components/settings/environment-form.test.tsx @@ -177,9 +177,69 @@ describe("EnvironmentForm", () => { fireEvent.submit(container.querySelector("form")!); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("teamId"); - expect(onSubmit.mock.calls[0][0].repositories).toEqual([ - { repoOwner: "acme", repoName: "web", baseBranch: "main" }, - ]); + }); + + it("omits an unchanged repository selection from edit submissions", async () => { + mocks.reposValue = [repo("Acme", "Web", 1), repo("acme", "api", 2)]; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.clear(screen.getByLabelText("Name")); + await user.type(screen.getByLabelText("Name"), "renamed"); + await user.click(screen.getByRole("button", { name: /save environment/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + name: "renamed", + description: null, + prebuildEnabled: false, + }); + }); + + it("sends the full selection when an edit changes only one base branch", async () => { + mocks.reposValue = [repo("group/subgroup", "web", 1), repo("acme", "api", 2)]; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + const webRow = screen.getByTitle("group/subgroup/web").closest("div") as HTMLElement; + await user.click(within(webRow).getByRole("button", { name: "main" })); + await user.click(screen.getByRole("option", { name: "develop" })); + await user.click(screen.getByRole("button", { name: /save environment/i })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + repositories: [ + { repoOwner: "group/subgroup", repoName: "web", baseBranch: "develop" }, + { repoOwner: "acme", repoName: "api", baseBranch: "main" }, + ], + }) + ); }); it("preserves a nested owner namespace when saving", async () => { @@ -188,7 +248,7 @@ describe("EnvironmentForm", () => { const user = userEvent.setup(); render( { /> ); - await user.click(screen.getByRole("button", { name: /save environment/i })); + await user.click(screen.getByRole("button", { name: /create environment/i })); expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/web/src/components/settings/environment-form.tsx b/packages/web/src/components/settings/environment-form.tsx index e204beea3d..c96bed0e70 100644 --- a/packages/web/src/components/settings/environment-form.tsx +++ b/packages/web/src/components/settings/environment-form.tsx @@ -29,7 +29,8 @@ export interface EnvironmentFormValues { name: string; description: string | null; prebuildEnabled: boolean; - repositories: RepositoryInput[]; + /** Omitted when an edit leaves the selection unchanged, so it is not revalidated as a replacement. */ + repositories?: RepositoryInput[]; } /** @@ -126,20 +127,33 @@ export function EnvironmentForm({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; + const repositories = selectedKeys.map((key) => { + const entry: RepositoryInput = parseRepositoryFullName(key) ?? { + repoOwner: "", + repoName: "", + }; + const branch = branchByKey[key]?.trim(); + if (branch) entry.baseBranch = branch; + return entry; + }); + const initialRepositories = initialValues?.repositories ?? []; + const repositoriesUnchanged = + mode === "edit" && + repositories.length === initialRepositories.length && + repositories.every( + (repository, index) => + selectedKeys[index] === + repositorySelectionKey( + initialRepositories[index].repoOwner, + initialRepositories[index].repoName + ) && (repository.baseBranch ?? "") === initialRepositories[index].baseBranch + ); onSubmit({ ...(mode === "create" ? { teamId } : {}), name: name.trim(), description: description.trim() ? description.trim() : null, prebuildEnabled, - repositories: selectedKeys.map((key) => { - const entry: RepositoryInput = parseRepositoryFullName(key) ?? { - repoOwner: "", - repoName: "", - }; - const branch = branchByKey[key]?.trim(); - if (branch) entry.baseBranch = branch; - return entry; - }), + ...(repositoriesUnchanged ? {} : { repositories }), }); }; From 3b66a3f1b182773788e96e8cdfcec5555e576333 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Fri, 2 Oct 2026 22:18:10 -0700 Subject: [PATCH 14/68] feat(web): reorganize analytics into an overview and tabs (#2233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reorganizes the analytics page into a one-row header and five tabs. The page opened with a hero card that filled most of the first screen (title, a caveat paragraph, three badges, and the two filters with five lines of help text), then ran to about 8,000px of cards at 1440×900, with usage, cost, tokens and people interleaved and a heading only on the pull request section. - **Header**: one toolbar row with the title, the window ("Sep 3 – Oct 2"), a freshness indicator, and the scope and range filters as segmented controls. The caveats move behind **About this data** (an icon on phones) and the scope descriptions behind an info button. - **Overview**: headline numbers grouped by what they're scoped to (**Human sessions**: sessions, active users, spend, completion rate; **Pull requests · every source**: PRs merged, cost per merged PR), sessions-per-day and PRs opened/merged charts, and top-five lists for repositories, models and people that link into their tabs. About one screen at 1440×900. - **Usage**: sessions per day, where sessions start (source → attributed users, keeping the attribution notes from #2218), how sessions ended, repositories, and automations for the Automations and All scopes. - **Cost**: spend, cache hit ratio, subscription-billed share, tokens, and private-session spend for Owners and Administrators (the API already returned `privateSessionsCostUsd`; the page never showed it); cost by model, provider and harness; most expensive runs; token totals. - **Pull requests**: the existing PR metrics, plus the outcome mix and cost per merged PR by model or harness. The scope control is disabled on this tab because PR metrics ignore scope. - **People**: the sortable per-person table, with a daily-sessions sparkline per person in place of the "Sessions Over Time" chart, which drew one overlapping area per user. Behavior changes: - Range, scope and tab are in the URL (`/analytics?days=7&scope=automation&tab=cost`), so a view can be linked and survives a reload. Defaults are left out. Changes go through `window.history.replaceState`, which Next syncs into `useSearchParams`; each change builds on the live URL, so quick successive changes compose instead of overwriting each other, and no server render is involved. - `useAnalyticsDashboard` uses SWR `keepPreviousData` and returns `{ dashboard, loading, stale, validating, error }`. While a new range or scope loads, the previous snapshot stays on screen, dimmed and `aria-busy`, and the header reads "Loading…". If that request fails, the page says the selected range failed to load and the header reads "Showing the previous selection" instead of a fresh "Updated" time. - Daily series are zero-filled across the window. The API omits days without activity, and the old charts drew straight across them. - **PRs merged** on the overview counts merges during the window, the same population as its daily-merges sparkline and the average time to merge. Cost per merged PR stays on the PRs opened in the window, as before. - Completion rate is "—" until a session finishes, everywhere (previously tables showed "0%"); sessions per person divides attributed sessions by attributed people, leaving out sessions with no recorded user. - Costs show cents below $1,000 and whole dollars above; sub-dollar values used to show four decimals ("$0.8565 per session"). Analytics has its own `formatAnalyticsCost`; `formatSessionCost` is unchanged. API change: - The session timeseries (dashboard `timeseries` and `GET /analytics/timeseries`) now keys each day's groups by the same user key as the user breakdown (user ID, else SCM login, else `__unknown__`) instead of by display name. Two people with the same name were merged into one series, which was fine for the old chart's name legend but wrong for per-person sparklines. The removed chart was the only consumer. The integration tests that pinned name grouping now pin identity grouping. Code: - One sortable `AnalyticsTable` replaces the dimension table, harness cards, repository and model bar charts, and the user table. Sort values may be `null`; those rows sort last in both directions, so callers no longer invent sentinels. `AnalyticsKpiStrip`/`AnalyticsKpiGroups`, `AnalyticsRankedBars`, `AnalyticsTrendChart` and `AnalyticsPanel` replace the other card styles. `SegmentedControl` is added to `components/ui` as a thin `ToggleGroup` wrapper. - Removes the 11 components the tabs replace, with their tests. Their behaviors are covered by the new tests: origin attribution and duplicate display names, subscription "—" vs 0, completion over finished sessions, sorting, and the PR-funnel-only rule for PR counts in the headline. - `DEFAULT_ANALYTICS_DAYS` moves to `@open-inspect/shared` beside `DEFAULT_ANALYTICS_SCOPE`, so the page default and the API default are one constant. - The new styles avoid Tailwind opacity modifiers on theme colors. Theme colors are bare `var()`s, so classes like `bg-destructive/60` emit no CSS; that is why the old "Cancelled" status bar never rendered. The same pattern exists elsewhere in the web app and is left for a separate change. - `administration/analytics.mdx` is rewritten for the tabbed page. ## Testing - `npm test -w @open-inspect/web` (2,847 passed), `npm run typecheck`, eslint, prettier, `npm run build -w @open-inspect/web` - `npm test -w @open-inspect/shared` (1,133 passed); control-plane typecheck, `src/db/analytics-store.test.ts`, `src/routes/analytics.test.ts`, and `test/integration/analytics.test.ts` (16 passed in workerd) - `npm test -w @open-inspect/docs` (44 passed), `npm run build -w @open-inspect/docs` - Manually against synthetic dashboard data in a local preview: every tab at 1440px and 390px, light and dark; two filter clicks without waiting both land in the URL and the page re-renders; tabs and overview links; reload keeps the view; per-person sparklines for every row; About this data on phones; no console errors. ## Summary by CodeRabbit * **New Features** * Redesigned Analytics as five tabs: Overview, Usage, Cost, Pull requests, and People, with metrics, charts, tables, and breakdowns tailored to each view. * Added URL-persisted time range, session scope, and tab selections. Previous results remain visible while new filters load. * Added sortable, expandable analytics tables and clearer session attribution, cost, and pull-request details. * **Documentation** * Updated Analytics guidance with tab contents, filtering behavior, calculations, refresh details, and troubleshooting information. --- .../control-plane/src/db/analytics-store.ts | 5 +- .../src/routes/analytics.test.ts | 3 +- .../control-plane/src/routes/analytics.ts | 2 +- .../test/integration/analytics.test.ts | 14 +- .../content/docs/administration/analytics.mdx | 143 +++-- packages/shared/src/types/analytics.ts | 5 + packages/shared/src/types/index.ts | 1 + .../(app)/(sidebar)/analytics/page.test.tsx | 525 ++++++------------ .../app/(app)/(sidebar)/analytics/page.tsx | 405 ++++---------- .../analytics/analytics-cost-tab.tsx | 46 ++ .../components/analytics/analytics-header.tsx | 214 +++++++ .../analytics/analytics-kpi-items.test.ts | 124 +++++ .../analytics/analytics-kpi-items.ts | 161 ++++++ .../components/analytics/analytics-kpis.tsx | 139 +++++ .../analytics/analytics-overview-tab.tsx | 149 +++++ .../components/analytics/analytics-panel.tsx | 58 ++ .../analytics/analytics-people-tab.tsx | 31 ++ .../analytics/analytics-pull-requests-tab.tsx | 67 +++ .../analytics/analytics-ranked-bars.tsx | 106 ++++ .../analytics/analytics-table.test.tsx | 170 ++++++ .../components/analytics/analytics-table.tsx | 209 +++++++ .../analytics/analytics-trend-chart.tsx | 163 ++++++ .../analytics/analytics-usage-tab.tsx | 65 +++ .../components/analytics/cost-table.test.tsx | 84 +++ .../src/components/analytics/cost-table.tsx | 143 +++++ .../analytics/dimension-table.test.tsx | 115 ---- .../components/analytics/dimension-table.tsx | 128 ----- .../analytics/harness-cards.test.tsx | 62 --- .../components/analytics/harness-cards.tsx | 61 -- .../analytics/model-bar-chart.test.tsx | 118 ---- .../components/analytics/model-bar-chart.tsx | 131 ----- .../analytics/people-table.test.tsx | 144 +++++ .../src/components/analytics/people-table.tsx | 144 +++++ .../analytics/pull-request-cards.tsx | 111 ---- .../analytics/pull-request-chart.tsx | 142 ----- .../pull-request-cost-table.test.tsx | 76 +-- .../analytics/pull-request-cost-table.tsx | 154 +++-- .../analytics/pull-request-outcomes.test.tsx | 35 ++ .../analytics/pull-request-outcomes.tsx | 51 ++ .../analytics/pull-request-repo-table.tsx | 150 ++--- .../analytics/pull-request-sources.tsx | 24 + .../components/analytics/repo-bar-chart.tsx | 159 ------ .../components/analytics/repository-table.tsx | 89 +++ .../components/analytics/runs-table.test.tsx | 56 +- .../src/components/analytics/runs-table.tsx | 190 ++++--- .../analytics/session-origins-card.tsx | 196 ------- ...card.test.tsx => session-sources.test.tsx} | 79 ++- .../components/analytics/session-sources.tsx | 151 +++++ .../analytics/session-status-summary.tsx | 46 ++ .../analytics/summary-cards.test.tsx | 47 -- .../components/analytics/summary-cards.tsx | 126 ----- .../components/analytics/timeseries-chart.tsx | 156 ------ .../components/analytics/token-cards.test.tsx | 65 --- .../src/components/analytics/token-cards.tsx | 70 --- .../src/components/analytics/token-totals.tsx | 23 + .../components/analytics/user-table.test.tsx | 109 ---- .../src/components/analytics/user-table.tsx | 288 ---------- .../src/components/ui/segmented-control.tsx | 52 ++ packages/web/src/hooks/use-analytics.test.tsx | 67 ++- packages/web/src/hooks/use-analytics.ts | 28 +- .../web/src/lib/analytics.test-fixture.ts | 167 ++++++ packages/web/src/lib/analytics.test.ts | 305 +++++----- packages/web/src/lib/analytics.ts | 338 +++++++---- 63 files changed, 4033 insertions(+), 3452 deletions(-) create mode 100644 packages/web/src/components/analytics/analytics-cost-tab.tsx create mode 100644 packages/web/src/components/analytics/analytics-header.tsx create mode 100644 packages/web/src/components/analytics/analytics-kpi-items.test.ts create mode 100644 packages/web/src/components/analytics/analytics-kpi-items.ts create mode 100644 packages/web/src/components/analytics/analytics-kpis.tsx create mode 100644 packages/web/src/components/analytics/analytics-overview-tab.tsx create mode 100644 packages/web/src/components/analytics/analytics-panel.tsx create mode 100644 packages/web/src/components/analytics/analytics-people-tab.tsx create mode 100644 packages/web/src/components/analytics/analytics-pull-requests-tab.tsx create mode 100644 packages/web/src/components/analytics/analytics-ranked-bars.tsx create mode 100644 packages/web/src/components/analytics/analytics-table.test.tsx create mode 100644 packages/web/src/components/analytics/analytics-table.tsx create mode 100644 packages/web/src/components/analytics/analytics-trend-chart.tsx create mode 100644 packages/web/src/components/analytics/analytics-usage-tab.tsx create mode 100644 packages/web/src/components/analytics/cost-table.test.tsx create mode 100644 packages/web/src/components/analytics/cost-table.tsx delete mode 100644 packages/web/src/components/analytics/dimension-table.test.tsx delete mode 100644 packages/web/src/components/analytics/dimension-table.tsx delete mode 100644 packages/web/src/components/analytics/harness-cards.test.tsx delete mode 100644 packages/web/src/components/analytics/harness-cards.tsx delete mode 100644 packages/web/src/components/analytics/model-bar-chart.test.tsx delete mode 100644 packages/web/src/components/analytics/model-bar-chart.tsx create mode 100644 packages/web/src/components/analytics/people-table.test.tsx create mode 100644 packages/web/src/components/analytics/people-table.tsx delete mode 100644 packages/web/src/components/analytics/pull-request-cards.tsx delete mode 100644 packages/web/src/components/analytics/pull-request-chart.tsx create mode 100644 packages/web/src/components/analytics/pull-request-outcomes.test.tsx create mode 100644 packages/web/src/components/analytics/pull-request-outcomes.tsx create mode 100644 packages/web/src/components/analytics/pull-request-sources.tsx delete mode 100644 packages/web/src/components/analytics/repo-bar-chart.tsx create mode 100644 packages/web/src/components/analytics/repository-table.tsx delete mode 100644 packages/web/src/components/analytics/session-origins-card.tsx rename packages/web/src/components/analytics/{session-origins-card.test.tsx => session-sources.test.tsx} (64%) create mode 100644 packages/web/src/components/analytics/session-sources.tsx create mode 100644 packages/web/src/components/analytics/session-status-summary.tsx delete mode 100644 packages/web/src/components/analytics/summary-cards.test.tsx delete mode 100644 packages/web/src/components/analytics/summary-cards.tsx delete mode 100644 packages/web/src/components/analytics/timeseries-chart.tsx delete mode 100644 packages/web/src/components/analytics/token-cards.test.tsx delete mode 100644 packages/web/src/components/analytics/token-cards.tsx create mode 100644 packages/web/src/components/analytics/token-totals.tsx delete mode 100644 packages/web/src/components/analytics/user-table.test.tsx delete mode 100644 packages/web/src/components/analytics/user-table.tsx create mode 100644 packages/web/src/components/ui/segmented-control.tsx create mode 100644 packages/web/src/lib/analytics.test-fixture.ts diff --git a/packages/control-plane/src/db/analytics-store.ts b/packages/control-plane/src/db/analytics-store.ts index 73b9d670d6..d485d78360 100644 --- a/packages/control-plane/src/db/analytics-store.ts +++ b/packages/control-plane/src/db/analytics-store.ts @@ -274,13 +274,12 @@ export class AnalyticsStore { .prepare( `SELECT s.created_at / ${MS_PER_DAY} AS day_index, - COALESCE(MAX(NULLIF(u.display_name, '')), MAX(NULLIF(s.scm_login, '')), '__unknown__') AS group_key, + ${USER_KEY_EXPRESSION} AS group_key, COUNT(*) AS count FROM sessions s - LEFT JOIN users u ON s.user_id = u.id WHERE s.created_at >= ? AND s.created_at < ? ${sql} ${visible.sql ? `AND ${visible.sql}` : ""} - GROUP BY day_index, COALESCE(s.user_id, '__unlinked__' || COALESCE(s.scm_login, '__none__')) + GROUP BY day_index, group_key ORDER BY day_index ASC, group_key ASC` ) .bind(filters.startAt, filters.endAt, ...binds, ...visible.params); diff --git a/packages/control-plane/src/routes/analytics.test.ts b/packages/control-plane/src/routes/analytics.test.ts index 96a5c64508..8f8228c43e 100644 --- a/packages/control-plane/src/routes/analytics.test.ts +++ b/packages/control-plane/src/routes/analytics.test.ts @@ -13,8 +13,9 @@ import { import type { SqlStatement } from "../db/sql-database"; import { AnalyticsStore } from "../db/analytics-store"; import { AnalyticsDashboardStore } from "../db/analytics-dashboard-store"; +import { DEFAULT_ANALYTICS_DAYS } from "@open-inspect/shared/types/analytics"; import { SessionRunStore } from "../db/session-run-store"; -import { analyticsRoutes, DEFAULT_ANALYTICS_DAYS } from "./analytics"; +import { analyticsRoutes } from "./analytics"; const FIXED_NOW = 1_700_000_000_000; const mockDashboardStore = { get: vi.fn() }; diff --git a/packages/control-plane/src/routes/analytics.ts b/packages/control-plane/src/routes/analytics.ts index 3b33d3bd01..1f6c9a2fa1 100644 --- a/packages/control-plane/src/routes/analytics.ts +++ b/packages/control-plane/src/routes/analytics.ts @@ -3,6 +3,7 @@ import { ANALYTICS_DAYS, ANALYTICS_RUN_ORDER_BY, ANALYTICS_SCOPES, + DEFAULT_ANALYTICS_DAYS, DEFAULT_ANALYTICS_SCOPE, type AnalyticsDays, type AnalyticsScope, @@ -30,7 +31,6 @@ import { requirePermission, } from "./shared"; -export const DEFAULT_ANALYTICS_DAYS: AnalyticsDays = 30; const DEFAULT_RUNS_LIMIT = 50; const MAX_RUNS_LIMIT = 100; diff --git a/packages/control-plane/test/integration/analytics.test.ts b/packages/control-plane/test/integration/analytics.test.ts index fcbbef433f..909be2021f 100644 --- a/packages/control-plane/test/integration/analytics.test.ts +++ b/packages/control-plane/test/integration/analytics.test.ts @@ -1184,20 +1184,21 @@ describe("Analytics API", () => { const summary = await summaryRes.json(); expect(summary.activeUsers).toBe(2); // user-abc + bob - // Timeseries: uses display name from users table + // Timeseries: keyed like the user breakdown, by user ID before SCM login const timeseriesRes = await serviceFetch("https://test.local/analytics/timeseries?days=30"); expect(timeseriesRes.status).toBe(200); const timeseries = await timeseriesRes.json(); - // All Alice's sessions should appear under "Alice Smith", not "alice"/"alice-gh" + // All Alice's sessions appear under her user ID, not "alice"/"alice-gh" or her name const allGroups = timeseries.series.flatMap((s) => Object.keys(s.groups)); - expect(allGroups).toContain("Alice Smith"); + expect(allGroups).toContain("user-abc"); + expect(allGroups).not.toContain("Alice Smith"); expect(allGroups).not.toContain("alice"); expect(allGroups).not.toContain("alice-gh"); expect(allGroups).toContain("bob"); }); - it("sums timeseries counts when distinct users share the same display name", async () => { + it("keeps distinct users who share a display name apart in the timeseries", async () => { const store = new SessionIndexStore(env.DB); const now = new Date().setUTCHours(12, 0, 0, 0); const dayAgo = now - 24 * 60 * 60 * 1000; @@ -1239,12 +1240,11 @@ describe("Analytics API", () => { expect(res.status).toBe(200); const body = await res.json(); - // Both sessions land on the same date with the same "Alex" label + // Both sessions land on the same date, each under its own user ID const dayBucket = dateBucket(dayAgo); const dayEntry = body.series.find((s) => s.date === dayBucket); expect(dayEntry).toBeDefined(); - // Reducer must sum, not overwrite: 1 + 1 = 2 - expect(dayEntry!.groups["Alex"]).toBe(2); + expect(dayEntry!.groups).toEqual({ "user-alex-1": 1, "user-alex-2": 1 }); }); it("keeps the default dashboard population and existing resources identical to explicit human scope", async () => { diff --git a/packages/docs/content/docs/administration/analytics.mdx b/packages/docs/content/docs/administration/analytics.mdx index 22fcb271bc..f6a5fa319c 100644 --- a/packages/docs/content/docs/administration/analytics.mdx +++ b/packages/docs/content/docs/administration/analytics.mdx @@ -1,90 +1,125 @@ --- title: Analytics -description: What the Analytics page measures, how each card and chart is calculated, and how session cost is derived. +description: What the Analytics page measures, how each number, chart, and table is calculated, and how session cost is derived. audience: team-owner owner: web status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-02" relatedCode: - packages/shared/src/types/analytics.ts - packages/web/src/app/(app)/(sidebar)/analytics/page.tsx - packages/web/src/lib/analytics.ts - - packages/web/src/components/analytics/summary-cards.tsx - - packages/web/src/components/analytics/user-table.tsx - - packages/web/src/components/analytics/pull-request-cards.tsx + - packages/web/src/components/analytics/analytics-kpi-items.ts + - packages/web/src/components/analytics/people-table.tsx + - packages/web/src/components/analytics/session-sources.tsx - packages/control-plane/src/db/analytics-store.ts - packages/control-plane/src/routes/analytics.ts --- -The Analytics page in the sidebar shows usage across sessions, repositories, and users for a chosen window, plus outcomes for the pull requests those sessions opened. +The Analytics page in the sidebar shows usage across sessions, repositories, and users for a chosen window, plus outcomes for the pull requests those sessions opened. It has five tabs: **Overview**, **Usage**, **Cost**, **Pull requests**, and **People**. ## Who can view it -Every role can open Analytics; it requires only `analytics.read`, which Viewers have. The page refreshes itself every 30 seconds. +Every role can open Analytics; it requires only `analytics.read`, which Viewers have. The page refreshes itself every 30 seconds; the header shows when the numbers were last updated. ## Time range -The **Time range** control offers **7d**, **14d**, **30d**, and **90d**. The default is 30 days. Changing the range re-filters windowed metrics on the page. +The **Time range** control offers **7d**, **14d**, **30d**, and **90d**. The default is 30 days. Changing the range re-filters windowed metrics on every tab. While the new range loads, the previous numbers stay on screen, dimmed, and the header reads **Loading…**. If the new range fails to load, the page says so and keeps showing the previous selection until a retry succeeds. -The window is the last N days ending now. Session metrics count sessions **created** in the window; open-PR inventory is measured as of now regardless of the window. +The window is the last N days ending now. Session metrics count sessions **created** in the window; open-PR inventory is measured as of now regardless of the window. Daily charts plot every day in the window, so a day without sessions shows as zero, and the last day covers only today so far. + +The range, scope, and tab are part of the page URL, so a link opens the same view. ## Which sessions are counted -The **Scope** control offers **Human** (the default: sessions with `user`, `slack-bot`, `linear-bot`, or `github-bot` spawn sources, including bot-triggered sessions), **Agents** (sessions spawned by other sessions), **Automations** (sessions started by automations), and **All** (every session). It filters session cards, charts, and breakdowns; the automation breakdown appears for Automations and All. The Pull Requests section is different: it counts every pull request created through OpenInspect regardless of session scope, including automation runs, and shows where they came from under **By Source**. +The **Scope** control offers **Human** (the default: sessions with `user`, `slack-bot`, `linear-bot`, or `github-bot` spawn sources, including bot-triggered sessions), **Agents** (sessions spawned by other sessions), **Automations** (sessions started by automations), and **All** (every session). The info button beside it explains each option. + +Scope filters every session number, chart, and table; the **Automations** table on the Usage tab appears for Automations and All. Pull request numbers are different: they count every pull request created through OpenInspect regardless of session scope, including automation runs. The Overview labels them **Pull requests · every source**, and on the **Pull requests** tab the scope control is turned off. + +**About this data** in the header lists the page's caveats. Older sessions recorded before cost, pull request, and duration values were tracked are still counted and may show zero for those values. PR counts reflect pull requests created through OpenInspect's own pull-request flow. + +## Overview + +The headline numbers come in two groups. The first is captioned with the scope (for example **Human sessions**): + +| Number | Value | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Sessions** | Sessions created in the window, with the daily average and a sparkline of sessions per day. | +| **Active users** | The number of distinct users with a session in the window, keyed by user ID and falling back to the source-control login for sessions without a linked user. The hint shows attributed sessions per person; sessions with no recorded user are left out of it. | +| **Spend** | The sum of every counted session's recorded cost, with the average per session. | +| **Completion rate** | Completed divided by completed + failed + cancelled. Sessions still running, never started, or archived are not in the denominator. A dash until a session finishes. | + +The second group, **Pull requests · every source**, shows **PRs merged** (pull requests merged during the window, with their average time to merge and a sparkline of merges per day) and **Cost per merged PR** (for pull requests opened in the window; defined under [Pull requests](#pull-requests)). + +Below them, **Sessions per day** and **Pull requests opened and merged** chart the window, and three lists show the top five repositories by sessions (with cost), models by spend (with cost per session), and people by sessions (with completion rate). Each panel links to the tab with the full detail. -The header carries an **Includes legacy sessions** badge. Older sessions recorded before cost, pull request, and duration values were tracked are still counted, and the page notes that they may show zero for those values. PR counts reflect pull requests created through OpenInspect's own pull-request flow. +## Usage -## Summary cards +**Sessions per day** charts the daily session count across the window. -| Card | Value | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Total Sessions** | Sessions created in the window ("Across the last N days"). | -| **Active Users** | "Distinct SCM logins": the number of distinct users with a session in the window, keyed by user ID and falling back to the source-control login for sessions without a linked user. | -| **Total Cost** | "Summed across sessions": the sum of every counted session's recorded cost. | -| **Avg Cost / Session** | "Average per session": Total Cost divided by Total Sessions. | +**Where sessions start** ranks session sources (User / app, Slack, GitHub, Linear, Agent sub-sessions, Automations) by sessions, beside the users those sessions are attributed to. Select a source to narrow the users to it; **All sources** resets. Sources describe how a session was created, not later messages. User / app includes user-authenticated creation and historical sessions whose source defaulted to user; it does not mean browser-only usage. Users reflect recorded attribution: the requesting actor, the attributed user for an agent sub-session, or the automation owner or manual triggerer. **How attribution works** under the panel repeats these rules. Private sessions are excluded. -Below the cards, **Status Mix** shows how many sessions in the window are in each status: completed, active, created, failed, cancelled, and archived. See [Lifecycle and statuses](/sessions/lifecycle-and-statuses). +**How sessions ended** shows the completion rate and the count of sessions in each status: completed, failed, cancelled, archived, running, and never started. See [Lifecycle and statuses](/sessions/lifecycle-and-statuses). -## Sessions Over Time +**Repositories** lists each repository's sessions, completion rate, PRs, messages, and cost. When every repository shares one owner, the owner is shown once above the table. Sessions with no repository are grouped under "No repository". -An area chart of daily session counts by user. The five users with the most sessions are listed as badges above the chart, with a "+N more" badge for the rest. Sessions without a linked user appear as "Unknown user". Hover a day to see each user's count. +**Automations** appears for the Automations and All scopes and lists each automation's sessions, cost, cost per session, completion rate, and PRs. -## Sessions by Repository +## Cost -A horizontal bar chart of session volume per repository, with **Tracked repos** and **Top repo** tiles. Hovering a bar shows that repository's sessions, cost, PRs, and messages. Sessions with no repository are grouped under "No repository". +The numbers across the top: -## Per-User Breakdown +| Number | Value | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **Spend** | The sum of every counted session's recorded cost, with the average per session. | +| **Cache hit ratio** | Cache reads divided by cache reads plus input tokens. | +| **Billed to subscriptions** | The share of sessions billed through a connected subscription. Those sessions report $0. | +| **Tokens** | Input plus output tokens, split into each. | +| **Private sessions** | The cost of private sessions in the same window and scope, which Spend excludes. Only Owners and Administrators see it. | -A sortable table. Click any column heading to sort; click again to reverse. +**By model**, **By provider**, and **By harness** list sessions, cost, and cost per session for each, sorted by cost. The provider table also shows how many sessions ran **On subscription**; the harness table shows the completion rate. Narrow screens hide secondary columns such as PRs and cache hit ratio. -| Column | Meaning | -| ------------------- | -------------------------------------------------------------------------------------------------------- | -| **User** | Display name or login. Rows labeled "Sessions without linked user" collect sessions with no user record. | -| **Sessions** | Session count, with badges for completed, failed, and cancelled sessions. | -| **Completion Rate** | Completed divided by completed + failed + cancelled. Sessions still running are not in the denominator. | -| **PRs** | Pull requests created by the user's sessions. | -| **Messages** | Prompts sent across the user's sessions. | -| **Total Cost** | Sum of recorded cost across the user's sessions. | -| **Avg Duration** | Average active duration per session; shown as a dash when there is none. | -| **Last Active** | Relative time of the user's most recent session activity. | +**Most expensive runs** lists the costliest runs in the window, up to 20. A run is a root session plus every sub-session it spawned, so its cost and session count include the whole family. Each run links to its root session. -## Pull Requests +**Tokens** gives the totals for input, output, cache read, cache write, and reasoning tokens. Reasoning tokens are reported by OpenCode only. Token totals cover sessions created since token capture was enabled; older sessions count as zero. -Rates in this section are computed over pull requests, not sessions. Sessions that never open a pull request (questions, research, debugging) are out of scope here by design. +## Pull requests -| Card | Value | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **PRs Created** | Pull requests opened in the window. | -| **Acceptance Rate** | Merged divided by resolved, where resolved is merged plus closed without merging. Still-open PRs are not in the denominator. Shown as a dash until something has resolved. The hint reads "N merged · M closed unmerged". | -| **Avg Time to Merge** | Mean time from open to merge for pull requests merged in the window; the hint counts "N merged in range". A dash when nothing merged. | -| **Open PRs** | Pull requests open right now, not limited to the window, with the average age, or "Nothing waiting on review". | -| **Cost / Merged PR** | The total cost of sessions that produced a pull request in the window, divided by the number merged. A dash until something has merged. | +Rates on this tab are computed over pull requests, not sessions. Sessions that never open a pull request (questions, research, debugging) are out of scope here by design. -**By Source** tiles show, for each origin of the sessions behind these pull requests (user, automation, Slack, and so on), how many were created and how many merged. +| Number | Value | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Opened** | Pull requests opened in the window. | +| **Acceptance rate** | Merged divided by resolved, where resolved is merged plus closed without merging. Still-open PRs are not in the denominator. Shown as a dash until something has resolved. The hint reads "N merged · M closed unmerged". | +| **Avg time to merge** | Mean time from open to merge for pull requests merged in the window; the hint counts "N merged in range". A dash when nothing merged. | +| **Open now** | Pull requests open right now, not limited to the window, with the average age, or "Nothing waiting on review". | +| **Cost per merged PR** | The total cost of sessions that produced a pull request in the window, divided by the number merged. A dash until something has merged. | -**Pull Requests Over Time** plots daily created versus merged counts. Created is bucketed by the day the PR was opened; merged by the day it merged. +**Pull requests over time** plots daily opened versus merged counts. Opened is bucketed by the day the PR was opened; merged by the day it merged. -**PRs by Repository** lists, for pull requests opened in the window: Repository, Created, Merged, Closed, Acceptance (the same merged ÷ resolved definition per repository), and Avg Time to Merge. +**Where they stand** splits the pull requests opened in the window into merged, open, draft, and closed unmerged, and lists **By source** where the sessions behind them came from (user or app, automation, Slack, and so on) with how many merged. + +**By repository** lists, for pull requests opened in the window: Opened, Merged, Closed, Acceptance (the same merged ÷ resolved definition per repository), and Avg time to merge. + +**Cost per merged PR** breaks that figure down by the **Models** or **Harnesses** of the sessions that produced the pull requests. + +## People + +A sortable table of usage per person. Click any column heading to sort; click again to reverse. + +| Column | Meaning | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| **User** | Display name or login. "Unknown user", marked "Sessions without linked user", collects sessions with no user record. | +| **Daily sessions** | A sparkline of the person's sessions per day across the window. | +| **Sessions** | Session count. | +| **Completion** | Completed divided by completed + failed + cancelled. Sessions still running are not in the denominator; a dash until one finishes. | +| **PRs** | Pull requests created by the person's sessions. | +| **Messages** | Prompts sent across the person's sessions. | +| **Cost** | Sum of recorded cost across the person's sessions. | +| **Avg duration** | Average active duration per finished session; shown as a dash when there is none. | +| **Last active** | Relative time of the person's most recent session activity. | + +**People by source** is the same breakdown as **Where sessions start** on the Usage tab. ## How cost is derived @@ -93,7 +128,7 @@ Cost figures are sums of each session's recorded cost. A session's cost is a run - **OpenCode** reports the priced cost of each step, and the session total accumulates those values. - **Claude Agent** reports a cumulative cost per turn, and the session records the increase over the previous turn. When the harness reports no cost for a turn, the turn is recorded as $0 and the session timeline shows a warning saying so. The next turn's cost cannot then be separated from the missing one, so it is also recorded as $0 before accounting resumes. -Nothing on this page estimates cost independently of what the harness reported. Values under $1 are shown with extra precision. Per-session cost caps are covered in [Spend limits](/sessions/spend-limits). +Nothing on this page estimates cost independently of what the harness reported. Amounts show cents below $1,000 and whole dollars above; amounts under one cent show as `<$0.01`. Per-session cost caps are covered in [Spend limits](/sessions/spend-limits). ## Troubleshooting @@ -104,14 +139,20 @@ Nothing on this page estimates cost independently of what the harness reported. - The sessions predate cost tracking, or their turns ran on a harness that reported no cost. Open - a session and check its timeline for a provider warning. + The sessions predate cost tracking, ran on a subscription that reports $0, or their turns ran + on a harness that reported no cost. Open a session and check its timeline for a provider + warning. - + A person can be counted twice when older sessions carry only a source-control login and newer ones carry a user ID. The count settles once historical sessions are linked to users. + + + You are on the Pull requests tab. Pull request numbers include every source, so scope does not + apply there. Switch to another tab to change it. + diff --git a/packages/shared/src/types/analytics.ts b/packages/shared/src/types/analytics.ts index aec376bab8..50af408690 100644 --- a/packages/shared/src/types/analytics.ts +++ b/packages/shared/src/types/analytics.ts @@ -2,6 +2,7 @@ import { spawnSourceSchema, type SpawnSource } from "./sessions"; export const ANALYTICS_DAYS = [7, 14, 30, 90] as const; export type AnalyticsDays = (typeof ANALYTICS_DAYS)[number]; +export const DEFAULT_ANALYTICS_DAYS: AnalyticsDays = 30; export const ANALYTICS_BREAKDOWN_BY = [ "user", @@ -80,6 +81,10 @@ export interface AnalyticsSummaryResponse extends AnalyticsTokenTotals { export interface AnalyticsTimeseriesPoint { date: string; + /** + * Sessions created that day per user, keyed like the user breakdown: user ID, + * else SCM login, else __unknown__. Display names live on the breakdown entries. + */ groups: Record; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 99a6d31422..0692e6ec60 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -411,6 +411,7 @@ export { ANALYTICS_DAYS, ANALYTICS_BREAKDOWN_BY, ANALYTICS_SCOPES, + DEFAULT_ANALYTICS_DAYS, DEFAULT_ANALYTICS_SCOPE, ANALYTICS_SPAWN_SOURCE_SCOPE, ANALYTICS_SCOPE_SPAWN_SOURCES, diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx index 281b8757b6..ebc29dedf0 100644 --- a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx @@ -1,424 +1,231 @@ // @vitest-environment jsdom /// -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, within, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; -import type { - AnalyticsBreakdownResponse, - AnalyticsSummaryResponse, - AnalyticsTimeseriesResponse, -} from "@open-inspect/shared/types/analytics"; +import type { AnalyticsDashboardResponse } from "@open-inspect/shared/types/analytics"; +import type * as TrendChartModule from "@/components/analytics/analytics-trend-chart"; +import { analyticsDashboard } from "@/lib/analytics.test-fixture"; import AnalyticsPage from "./page"; expect.extend(matchers); -const zeroTokens = { - inputTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, -}; - -const { mockUseAnalyticsDashboard, mockUseSidebarContext } = vi.hoisted(() => ({ +const { mockUseAnalyticsDashboard } = vi.hoisted(() => ({ mockUseAnalyticsDashboard: vi.fn(), - mockUseSidebarContext: vi.fn(), -})); - -vi.mock("@/hooks/use-analytics", () => ({ - useAnalyticsDashboard: mockUseAnalyticsDashboard, -})); - -vi.mock("@/components/sidebar-layout", () => ({ - useSidebarContext: mockUseSidebarContext, -})); - -vi.mock("@/components/analytics/summary-cards", () => ({ - AnalyticsSummaryCards: () =>
, -})); - -vi.mock("@/components/analytics/token-cards", () => ({ - AnalyticsTokenCards: () =>
, })); -vi.mock("@/components/analytics/model-bar-chart", () => ({ - AnalyticsModelBarChart: ({ entries }: { entries?: AnalyticsBreakdownResponse["entries"] }) => ( -
- ), +// Next syncs useSearchParams with native history calls; read the jsdom URL the same way. +vi.mock("next/navigation", () => ({ + usePathname: () => "/analytics", + useSearchParams: () => new URLSearchParams(window.location.search), })); -vi.mock("@/components/analytics/dimension-table", () => ({ - AnalyticsDimensionTable: ({ - title, - entries, - }: { - title: string; - entries?: AnalyticsBreakdownResponse["entries"]; - }) => ( -
+vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: React.ComponentProps<"a">) => ( + + {children} + ), })); -vi.mock("@/components/analytics/harness-cards", () => ({ - AnalyticsHarnessCards: ({ entries }: { entries?: AnalyticsBreakdownResponse["entries"] }) => ( -
- ), +vi.mock("@/hooks/use-analytics", () => ({ + useAnalyticsDashboard: mockUseAnalyticsDashboard, })); -vi.mock("@/components/analytics/runs-table", () => ({ - AnalyticsRunsTable: ({ runs }: { runs?: unknown[] }) => ( -
- ), +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), })); -vi.mock("@/components/analytics/pull-request-cost-table", () => ({ - AnalyticsPullRequestCostTable: ({ title, entries }: { title: string; entries?: unknown[] }) => ( -
+// Charts need layout jsdom does not have; the panels around them are what is under test. +vi.mock("@/components/analytics/analytics-trend-chart", async (importOriginal) => ({ + ...(await importOriginal()), + AnalyticsTrendChart: ({ data }: { data: unknown[] }) => ( +
), })); -vi.mock("@/components/analytics/pull-request-cards", () => ({ - AnalyticsPullRequestCards: () =>
, -})); - -vi.mock("@/components/analytics/timeseries-chart", () => ({ - AnalyticsTimeseriesChart: () =>
, -})); - -vi.mock("@/components/analytics/repo-bar-chart", () => ({ - AnalyticsRepoBarChart: () =>
, -})); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -const summary: AnalyticsSummaryResponse = { - ...zeroTokens, - cacheHitRatio: null, - totalSessions: 13, - activeUsers: 3, - totalCost: 12.5, - privateSessionsCostUsd: 0, - avgCost: 0.96, - totalPrs: 4, - statusBreakdown: { - created: 0, - active: 1, - completed: 10, - failed: 1, - archived: 0, - cancelled: 1, - }, -}; - -const timeseries: AnalyticsTimeseriesResponse = { - series: [ - { - date: "2026-04-10", - groups: { - zoe: 2, - anna: 1, - }, - }, - ], -}; - -const repoBreakdown: AnalyticsBreakdownResponse = { - entries: [ - { - key: "open-inspect/background-agents", - ...zeroTokens, - sessions: 8, - completed: 7, - failed: 1, - cancelled: 0, - cost: 8.25, - prs: 3, - messageCount: 42, - avgDuration: 120000, - lastActive: Date.UTC(2026, 3, 12), - }, - ], -}; - -const userBreakdown: AnalyticsBreakdownResponse = { - entries: [ - { - key: "zoe", - ...zeroTokens, - sessions: 8, - completed: 7, - failed: 1, - cancelled: 0, - cost: 8.25, - prs: 3, - messageCount: 42, - avgDuration: 120000, - lastActive: Date.UTC(2026, 3, 12), - }, - { - key: "anna", - ...zeroTokens, - sessions: 3, - completed: 2, - failed: 0, - cancelled: 1, - cost: 2.1, - prs: 1, - messageCount: 14, - avgDuration: 60000, - lastActive: Date.UTC(2026, 3, 10), - }, - { - key: "mike", - ...zeroTokens, - sessions: 1, - completed: 1, - failed: 0, - cancelled: 0, - cost: 0.4, - prs: 0, - messageCount: 3, - avgDuration: 15000, - lastActive: Date.UTC(2026, 3, 9), - }, - ], -}; - -function renderPage() { - mockUseSidebarContext.mockReturnValue({ - isOpen: true, - toggle: vi.fn(), - }); - - mockUseAnalyticsDashboard.mockImplementation(() => ({ - summary, - timeseries, - repoBreakdown, - userBreakdown, +function dashboardResult( + overrides: Partial<{ + dashboard: AnalyticsDashboardResponse | undefined; + loading: boolean; + stale: boolean; + validating: boolean; + error: unknown; + }> = {} +) { + return { + dashboard: analyticsDashboard(), loading: false, + stale: false, + validating: false, error: undefined, - })); + ...overrides, + }; +} +function renderAt(search: string, result = dashboardResult()) { + window.history.replaceState(null, "", search ? `/analytics?${search}` : "/analytics"); + mockUseAnalyticsDashboard.mockReturnValue(result); return render(); } -function getUserRows() { - const rows = within(screen.getByRole("table")).getAllByRole("row"); - return rows.slice(1); -} +beforeEach(() => { + mockUseAnalyticsDashboard.mockReset(); +}); + +afterEach(cleanup); describe("AnalyticsPage", () => { - it("renders session origins and resets the local source selection on range and scope changes", async () => { - const user = userEvent.setup(); - renderPage(); - mockUseAnalyticsDashboard.mockReturnValue({ - summary, - sessionOrigins: [{ source: "slack-bot", userKey: "zoe", displayName: "Zoe", sessions: 8 }], - loading: false, - }); - await user.click(screen.getByRole("radio", { name: "7d" })); - let origins = within(screen.getByRole("region", { name: "Session origins" })); - await user.click(origins.getByRole("button", { name: /Slack/ })); - expect(origins.getByRole("button", { name: /Slack/ })).toHaveAttribute("aria-pressed", "true"); - await user.click(screen.getByRole("radio", { name: "14d" })); - origins = within(screen.getByRole("region", { name: "Session origins" })); - expect(origins.getByRole("button", { name: "All sources" })).toHaveAttribute( - "aria-pressed", - "true" - ); - await user.click(origins.getByRole("button", { name: /Slack/ })); - await user.click(screen.getByRole("radio", { name: "All" })); - origins = within(screen.getByRole("region", { name: "Session origins" })); - expect(origins.getByRole("button", { name: "All sources" })).toHaveAttribute( - "aria-pressed", - "true" + it("opens on the overview with headline numbers grouped by what scope applies to", () => { + renderAt(""); + + expect(mockUseAnalyticsDashboard).toHaveBeenCalledWith(30, "human"); + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("data-state", "active"); + expect(screen.getByRole("region", { name: "Human sessions" })).toHaveTextContent("12"); + expect(screen.getByRole("region", { name: "Pull requests · every source" })).toHaveTextContent( + "$1.50" ); + expect(screen.getByRole("region", { name: "Top repositories" })).toHaveTextContent("web"); + expect(screen.getAllByTestId("trend-chart")[0]).toHaveAttribute("data-points", "8"); }); - it("shows automation only for automation and all scopes and orders the new views", async () => { - const user = userEvent.setup(); - renderPage(); - mockUseAnalyticsDashboard.mockImplementation(() => ({ - summary, - timeseries, - repoBreakdown, - userBreakdown, - harnessBreakdown: { entries: [repoBreakdown.entries[0]] }, - automationBreakdown: { entries: [repoBreakdown.entries[0]] }, - runs: [{ rootSessionId: "root-1" }], - pullRequests: { models: [{ key: "model-1" }], harnesses: [{ key: "harness-1" }] }, - loading: false, - })); - - expect(screen.queryByTestId("analytics-automation-table")).not.toBeInTheDocument(); - await user.click(screen.getByRole("radio", { name: "Agents" })); - expect(screen.queryByTestId("analytics-automation-table")).not.toBeInTheDocument(); - await user.click(screen.getByRole("radio", { name: "Automations" })); - expect(screen.getByTestId("analytics-automation-table")).toHaveAttribute( - "data-entries", - JSON.stringify([repoBreakdown.entries[0]]) - ); - await user.click(screen.getByRole("radio", { name: "All" })); - expect(screen.getByTestId("analytics-automation-table")).toBeInTheDocument(); - await user.click(screen.getByRole("radio", { name: "Human" })); - expect(screen.queryByTestId("analytics-automation-table")).not.toBeInTheDocument(); - - expect(screen.getByTestId("analytics-harness-cards")).toHaveAttribute( - "data-entries", - JSON.stringify([repoBreakdown.entries[0]]) - ); - expect(screen.getByTestId("analytics-runs-table")).toHaveAttribute( - "data-runs", - JSON.stringify([{ rootSessionId: "root-1" }]) - ); - expect(screen.getByTestId("analytics-pr-model-cost")).toHaveAttribute( - "data-entries", - JSON.stringify([{ key: "model-1" }]) - ); - expect(screen.getByTestId("analytics-pr-harness-cost")).toHaveAttribute( - "data-entries", - JSON.stringify([{ key: "harness-1" }]) - ); - const widgets = Array.from(document.querySelectorAll("[data-testid]")).map((node) => - node.getAttribute("data-testid") - ); - expect(widgets.indexOf("analytics-harness-cards")).toBeGreaterThan( - widgets.indexOf("analytics-provider-table") - ); - expect(widgets.indexOf("analytics-runs-table")).toBeGreaterThan( - widgets.indexOf("analytics-harness-cards") - ); - expect(widgets.indexOf("analytics-pr-model-cost")).toBeGreaterThan( - widgets.indexOf("analytics-pr-cards") - ); - expect(widgets.indexOf("analytics-pr-harness-cost")).toBeGreaterThan( - widgets.indexOf("analytics-pr-model-cost") - ); + it("requests the range and scope in the URL and opens the tab it names", () => { + renderAt("days=7&scope=agent&tab=cost"); + + expect(mockUseAnalyticsDashboard).toHaveBeenCalledWith(7, "agent"); + expect(screen.getByRole("tab", { name: "Cost" })).toHaveAttribute("data-state", "active"); + expect(screen.getByRole("radio", { name: "7d" })).toHaveAttribute("data-state", "on"); + expect(screen.getByRole("radio", { name: "Agents" })).toHaveAttribute("data-state", "on"); + expect(screen.getByRole("region", { name: "By provider" })).toBeInTheDocument(); }); - it("refetches analytics when the selected range changes", async () => { + it("writes filter changes to the URL, keeps the open tab, and leaves defaults out", async () => { const user = userEvent.setup(); + const { rerender } = renderAt("tab=cost"); - renderPage(); + await user.click(screen.getByRole("radio", { name: "7d" })); + expect(window.location.search).toBe("?tab=cost&days=7"); - expect(mockUseAnalyticsDashboard).toHaveBeenCalledWith(30, "human"); + // Next re-renders the page from the new URL; the test does it by hand. + rerender(); + expect(mockUseAnalyticsDashboard).toHaveBeenLastCalledWith(7, "human"); + await user.click(screen.getByRole("radio", { name: "30d" })); + expect(window.location.search).toBe("?tab=cost"); + }); - await user.click(screen.getByRole("radio", { name: "7d" })); + it("keeps both of two quick changes made before the page re-renders", async () => { + const user = userEvent.setup(); + renderAt("tab=cost"); - await waitFor(() => { - expect(mockUseAnalyticsDashboard).toHaveBeenLastCalledWith(7, "human"); - }); + // Neither click re-renders the page here, so the second change must build on the + // URL the first one wrote rather than on the params from the last render. + await user.click(screen.getByRole("radio", { name: "7d" })); + await user.click(screen.getByRole("radio", { name: "Automations" })); + expect(window.location.search).toBe("?tab=cost&days=7&scope=automation"); }); - it("refetches analytics when the selected scope changes", async () => { + it("switches tabs through the URL, including from overview links", async () => { const user = userEvent.setup(); - renderPage(); - mockUseAnalyticsDashboard.mockImplementation((_days, selectedScope) => ({ - summary, - timeseries, - repoBreakdown, - userBreakdown, - modelBreakdown: { - entries: [ - { - ...repoBreakdown.entries[0], - key: "anthropic/sonnet", - cost: selectedScope === "agent" ? 7 : 2, - }, - ], - }, - loading: false, - })); - - expect(screen.getByRole("radio", { name: "Human" })).toHaveAttribute("data-state", "on"); - expect( - screen.getByText(/Automations: sessions started by automations\. All: every session\./) - ).toBeInTheDocument(); - await user.click(screen.getByRole("radio", { name: "Agents" })); - - await waitFor(() => { - expect(mockUseAnalyticsDashboard).toHaveBeenLastCalledWith(30, "agent"); - }); - expect(screen.getByTestId("analytics-token-cards")).toBeInTheDocument(); - expect(screen.getByTestId("analytics-model-chart")).toBeInTheDocument(); - expect( - JSON.parse(screen.getByTestId("analytics-model-chart").dataset.entries ?? "[]") - ).toMatchObject([{ key: "anthropic/sonnet", cost: 7 }]); - expect(screen.getByTestId("analytics-provider-table")).toBeInTheDocument(); + renderAt("days=14"); + + await user.click(screen.getByRole("tab", { name: "Pull requests" })); + expect(window.location.search).toBe("?days=14&tab=pull-requests"); + + const topPeople = screen.getByRole("region", { name: "Most active people" }); + await user.click(within(topPeople).getByRole("button", { name: "People" })); + expect(window.location.search).toBe("?days=14&tab=people"); }); - it("renders cached dimensions when a refresh fails without summary data", () => { - mockUseSidebarContext.mockReturnValue({ isOpen: true }); - mockUseAnalyticsDashboard.mockReturnValue({ - modelBreakdown: { entries: [{ key: "a" }] }, - error: new Error("request failed"), - loading: false, - }); + it("turns the scope control off on the pull requests tab, where it does not apply", () => { + renderAt("tab=pull-requests"); - render(); + for (const scope of ["Human", "Agents", "Automations", "All"]) { + expect(screen.getByRole("radio", { name: scope })).toBeDisabled(); + } + expect(screen.getByRole("radio", { name: "30d" })).toBeEnabled(); + expect(screen.getByRole("region", { name: "By repository" })).toBeInTheDocument(); + }); - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect(screen.getByTestId("analytics-model-chart")).toBeInTheDocument(); + it("shows automations only for automation and all scopes", () => { + const { unmount } = renderAt("tab=usage"); + expect(screen.queryByRole("region", { name: "Automations" })).not.toBeInTheDocument(); + unmount(); + + const dashboard = analyticsDashboard(); + for (const scope of ["automation", "all"] as const) { + const view = renderAt( + `tab=usage&scope=${scope}`, + dashboardResult({ dashboard: { ...dashboard, window: { ...dashboard.window, scope } } }) + ); + expect(screen.getByRole("region", { name: "Automations" })).toBeInTheDocument(); + view.unmount(); + } }); - it("re-sorts the per-user table when a header is clicked", async () => { + it("resets the source selection when the range or scope changes", async () => { const user = userEvent.setup(); + const { rerender } = renderAt("tab=usage"); + const sources = () => screen.getByRole("region", { name: "Where sessions start" }); - renderPage(); + await user.click(within(sources()).getByRole("button", { name: /Slack/ })); + expect(within(sources()).getByRole("button", { name: /Slack/ })).toHaveAttribute( + "aria-pressed", + "true" + ); - let rows = getUserRows(); - expect(within(rows[0]).getByText("zoe")).toBeInTheDocument(); - expect(within(rows[1]).getByText("anna")).toBeInTheDocument(); + window.history.replaceState(null, "", "/analytics?tab=usage&days=7"); + rerender(); + expect(within(sources()).getByRole("button", { name: "All sources" })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); - await user.click(screen.getByRole("button", { name: /user/i })); + it("keeps cached numbers on screen behind the error alert", () => { + renderAt("", dashboardResult({ error: new Error("refresh failed") })); - rows = getUserRows(); - expect(within(rows[0]).getByText("anna")).toBeInTheDocument(); - expect(within(rows[1]).getByText("mike")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Analytics failed to load"); + expect(screen.getByRole("region", { name: "Human sessions" })).toBeInTheDocument(); + }); - await user.click(screen.getByRole("button", { name: /user/i })); + it("shows only the alert when loading fails with nothing cached", () => { + renderAt("", dashboardResult({ dashboard: undefined, error: new Error("request failed") })); - rows = getUserRows(); - expect(within(rows[0]).getByText("zoe")).toBeInTheDocument(); - expect(within(rows[1]).getByText("mike")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Human sessions" })).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); - it("shows the alert without rendering widgets when loading fails with no cached data", () => { - mockUseSidebarContext.mockReturnValue({ - isOpen: true, - toggle: vi.fn(), - }); + it("dims the previous snapshot as busy while a new range loads", () => { + renderAt("", dashboardResult({ stale: true, validating: true })); - mockUseAnalyticsDashboard.mockImplementation(() => ({ - summary: undefined, - timeseries: undefined, - repoBreakdown: undefined, - userBreakdown: undefined, - loading: false, - error: new Error("request failed"), - })); + expect( + screen.getByRole("region", { name: "Human sessions" }).closest("[aria-busy]") + ).toHaveAttribute("aria-busy", "true"); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + expect(screen.queryByText(/^Updated/)).not.toBeInTheDocument(); + }); - render(); + it("says when a failed range change leaves the previous selection on screen", () => { + renderAt( + "days=7", + dashboardResult({ stale: true, validating: false, error: new Error("range failed") }) + ); - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect(screen.queryByTestId("analytics-summary-cards")).not.toBeInTheDocument(); - expect(screen.queryByTestId("analytics-timeseries-chart")).not.toBeInTheDocument(); - expect(screen.queryByTestId("analytics-repo-chart")).not.toBeInTheDocument(); - expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Analytics for the selected range and scope failed to load" + ); + expect(screen.getByText("Showing the previous selection")).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Human sessions" }).closest("[aria-busy]") + ).toHaveAttribute("aria-busy", "false"); + }); + + it("shows a placeholder on first load", () => { + renderAt("", dashboardResult({ dashboard: undefined, loading: true })); + expect(screen.getByRole("status", { name: "Loading analytics" })).toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx index 4229eeaf86..bf2db038e3 100644 --- a/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.tsx @@ -1,305 +1,144 @@ "use client"; -import { useMemo, useState } from "react"; -import { - ANALYTICS_SCOPES, - DEFAULT_ANALYTICS_SCOPE, - type AnalyticsDays, - type AnalyticsScope, -} from "@open-inspect/shared/types/analytics"; -import { AnalyticsDimensionTable } from "@/components/analytics/dimension-table"; -import { AnalyticsHarnessCards } from "@/components/analytics/harness-cards"; -import { AnalyticsModelBarChart } from "@/components/analytics/model-bar-chart"; -import { AnalyticsPullRequestCards } from "@/components/analytics/pull-request-cards"; -import { AnalyticsPullRequestChart } from "@/components/analytics/pull-request-chart"; -import { AnalyticsPullRequestCostTable } from "@/components/analytics/pull-request-cost-table"; -import { AnalyticsPullRequestRepoTable } from "@/components/analytics/pull-request-repo-table"; -import { AnalyticsRepoBarChart } from "@/components/analytics/repo-bar-chart"; -import { AnalyticsRunsTable } from "@/components/analytics/runs-table"; -import { AnalyticsSummaryCards } from "@/components/analytics/summary-cards"; -import { AnalyticsSessionOriginsCard } from "@/components/analytics/session-origins-card"; -import { AnalyticsTimeseriesChart } from "@/components/analytics/timeseries-chart"; -import { AnalyticsTokenCards } from "@/components/analytics/token-cards"; -import { AnalyticsUserTable } from "@/components/analytics/user-table"; -import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; -import { Badge } from "@/components/ui/badge"; +import { Suspense, useRef } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { AnalyticsCostTab } from "@/components/analytics/analytics-cost-tab"; +import { AnalyticsHeader } from "@/components/analytics/analytics-header"; +import { AnalyticsOverviewTab } from "@/components/analytics/analytics-overview-tab"; +import { AnalyticsPeopleTab } from "@/components/analytics/analytics-people-tab"; +import { AnalyticsPullRequestsTab } from "@/components/analytics/analytics-pull-requests-tab"; +import { AnalyticsUsageTab } from "@/components/analytics/analytics-usage-tab"; import { ErrorBanner } from "@/components/ui/error-banner"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAnalyticsDashboard } from "@/hooks/use-analytics"; import { - ANALYTICS_DAYS, - ANALYTICS_REFRESH_INTERVAL_MS, - ANALYTICS_RANGE_LABELS, - ANALYTICS_SCOPE_LABELS, - formatAnalyticsCount, - sortAnalyticsUserEntries, - type AnalyticsSortDirection, - type AnalyticsUserSortKey, + ANALYTICS_TAB_LABELS, + ANALYTICS_TABS, + buildAnalyticsSearch, + parseAnalyticsView, + type AnalyticsTab, + type AnalyticsView, } from "@/lib/analytics"; +import { cn } from "@/lib/utils"; export default function AnalyticsPage() { - const { isOpen } = useSidebarContext(); - const [days, setDays] = useState(30); - const [scope, setScope] = useState(DEFAULT_ANALYTICS_SCOPE); - const [sortKey, setSortKey] = useState("sessions"); - const [sortDirection, setSortDirection] = useState("desc"); - const { - summary, - sessionOrigins, - timeseries, - repoBreakdown, - userBreakdown, - modelBreakdown, - harnessBreakdown, - providerBreakdown, - automationBreakdown, - runs, - pullRequests, - loading, - error, - } = useAnalyticsDashboard(days, scope); - const userEntries = userBreakdown?.entries; - - const sortedUserEntries = useMemo( - () => (userEntries ? sortAnalyticsUserEntries(userEntries, sortKey, sortDirection) : undefined), - [sortDirection, sortKey, userEntries] - ); - const hasCachedData = Boolean( - summary || - sessionOrigins?.length || - timeseries?.series?.length || - repoBreakdown?.entries?.length || - sortedUserEntries?.length || - modelBreakdown?.entries?.length || - harnessBreakdown?.entries?.length || - providerBreakdown?.entries?.length || - automationBreakdown?.entries?.length || - runs?.length || - pullRequests + return ( + + + ); +} - function handleSort(nextKey: AnalyticsUserSortKey) { - if (nextKey === sortKey) { - setSortDirection((current) => (current === "desc" ? "asc" : "desc")); - return; - } +function AnalyticsContent() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const view = parseAnalyticsView(searchParams); + const { dashboard, loading, stale, validating, error } = useAnalyticsDashboard( + view.days, + view.scope + ); + // Waiting on the selected filters, versus left with the previous ones after a failure. + const freshness = !stale ? "current" : validating ? "loading" : "previous"; + const scrollRef = useRef(null); + const filterKey = `${view.days}-${view.scope}`; + + function changeView(change: Partial) { + // Build on the address bar, not the last rendered params: replaceState updates it + // synchronously, so quick successive changes compose instead of overwriting each other. + // Next syncs useSearchParams with native history calls, but only when the state passed + // is not its own (it skips state marked as internal), so pass null as its docs do. + const query = buildAnalyticsSearch(new URLSearchParams(window.location.search), change); + window.history.replaceState(null, "", query ? `${pathname}?${query}` : pathname); + } - setSortKey(nextKey); - setSortDirection(nextKey === "user" ? "asc" : "desc"); + function selectTab(tab: AnalyticsTab) { + changeView({ tab }); + if (scrollRef.current) scrollRef.current.scrollTop = 0; } return ( -
-
-
- - {!isOpen && ( -
-
- -
-
- )} - -
-
-
-
- -
-
-
- Usage analytics -
-
-

Analytics

-

- Usage metrics across sessions, repositories, and users. PR counts currently - reflect pull requests created through the platform's built-in flow, and - legacy sessions may show zero cost, PR, or duration values. -

-
-
- - Refreshes every {ANALYTICS_REFRESH_INTERVAL_MS / 1000}s - - Includes legacy sessions - {summary ? ( - - {formatAnalyticsCount(summary.totalSessions)} sessions in range - - ) : null} -
-
- -
-
-
- Time range -
-
- { - if (!value) return; - setDays(Number(value) as AnalyticsDays); - }} - variant="outline" - size="sm" - className="grid grid-cols-4 gap-1 rounded-md bg-card p-1" - > - {ANALYTICS_DAYS.map((range) => ( - - {ANALYTICS_RANGE_LABELS[range]} - - ))} - -
-
- Session charts follow the selected range and scope. -
-
- Scope -
-
- { - const nextScope = ANALYTICS_SCOPES.find((option) => option === value); - if (nextScope) setScope(nextScope); - }} - aria-label="Session scope" - variant="outline" - size="sm" - className="grid grid-cols-4 gap-1 rounded-md bg-card p-1" - > - {ANALYTICS_SCOPES.map((option) => ( - - {ANALYTICS_SCOPE_LABELS[option]} - - ))} - -
-
- Human: user/app and integration sessions (Slack, Linear and GitHub). Agents: - sessions spawned by other sessions. Automations: sessions started by - automations. All: every session. -
-
-
-
-
- + { + const tab = ANALYTICS_TABS.find((candidate) => candidate === value); + if (tab) selectTab(tab); + }} + className="h-full" + > +
+ changeView({ days })} + onScopeChange={(scope) => changeView({ scope })} + scopeDisabled={view.tab === "pull-requests"} + freshness={freshness} + tabs={ + + {ANALYTICS_TABS.map((tab) => ( + + {ANALYTICS_TAB_LABELS[tab]} + + ))} + + } + /> + +
{error ? ( - - Analytics failed to load. The page will retry automatically, or you can refresh. + + {freshness === "previous" + ? "Analytics for the selected range and scope failed to load. The previous selection is shown, dimmed, while the page retries." + : "Analytics failed to load. The page will retry automatically, or you can refresh."} ) : null} - {!error || hasCachedData ? ( - <> - - - - - - -
- - -
- -
- - -
- - - - {(scope === "automation" || scope === "all") && ( - - )} - - - - - -
-

Pull Requests

-

- Outcomes for pull requests created through the platform, including - automation-created ones. Rates are computed over pull requests, not sessions — - sessions that don't open a PR (Q&A, research, debugging) are out of scope - here by design. -

-
- - - -
- - -
- -
- - -
- + {dashboard ? ( + // A snapshot for other filters stays dimmed until the selected one arrives. +
+ + + + + + + + + + + + + + + +
+ ) : loading ? ( + ) : null} -
+ +
+ + ); +} + +function AnalyticsLoading() { + return ( +
+
+
+
+
); diff --git a/packages/web/src/components/analytics/analytics-cost-tab.tsx b/packages/web/src/components/analytics/analytics-cost-tab.tsx new file mode 100644 index 0000000000..a907bf5348 --- /dev/null +++ b/packages/web/src/components/analytics/analytics-cost-tab.tsx @@ -0,0 +1,46 @@ +import type { AnalyticsDashboardResponse } from "@open-inspect/shared/types/analytics"; +import { getCostKpis } from "./analytics-kpi-items"; +import { AnalyticsKpiStrip } from "./analytics-kpis"; +import { AnalyticsPanel } from "./analytics-panel"; +import { AnalyticsCostTable } from "./cost-table"; +import { AnalyticsRunsTable } from "./runs-table"; +import { AnalyticsTokenTotals } from "./token-totals"; + +/** What the sessions in scope cost, and where the spend goes. */ +export function AnalyticsCostTab({ dashboard }: { dashboard: AnalyticsDashboardResponse }) { + return ( +
+ +
+ + + +
+ + + + + + +
+
+ + + + + + +
+ ); +} diff --git a/packages/web/src/components/analytics/analytics-header.tsx b/packages/web/src/components/analytics/analytics-header.tsx new file mode 100644 index 0000000000..02d45641e8 --- /dev/null +++ b/packages/web/src/components/analytics/analytics-header.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { + ANALYTICS_DAYS, + ANALYTICS_SCOPES, + type AnalyticsDashboardResponse, + type AnalyticsDays, + type AnalyticsScope, +} from "@open-inspect/shared/types/analytics"; +import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { cn } from "@/lib/utils"; +import { + ANALYTICS_RANGE_LABELS, + ANALYTICS_REFRESH_INTERVAL_MS, + ANALYTICS_SCOPE_DESCRIPTIONS, + ANALYTICS_SCOPE_LABELS, + formatAnalyticsWindow, +} from "@/lib/analytics"; + +const RANGE_OPTIONS = ANALYTICS_DAYS.map((days) => ({ + value: String(days) as `${AnalyticsDays}`, + label: ANALYTICS_RANGE_LABELS[days], +})); + +const SCOPE_OPTIONS = ANALYTICS_SCOPES.map((scope) => ({ + value: scope, + label: ANALYTICS_SCOPE_LABELS[scope], +})); + +/** + * The page header as one toolbar row (title, window, freshness, then the filters + * that scope everything below) with the tab list beneath it. + */ +export function AnalyticsHeader({ + dashboard, + days, + scope, + onDaysChange, + onScopeChange, + scopeDisabled, + freshness, + tabs, +}: { + dashboard?: AnalyticsDashboardResponse; + days: AnalyticsDays; + scope: AnalyticsScope; + onDaysChange: (days: AnalyticsDays) => void; + onScopeChange: (scope: AnalyticsScope) => void; + /** Pull request metrics ignore scope, so their tab turns the control off. */ + scopeDisabled: boolean; + /** Whether the numbers on screen match the selected filters. */ + freshness: AnalyticsFreshness; + tabs: ReactNode; +}) { + const { isOpen } = useSidebarContext(); + return ( +
+
+ {!isOpen && } +
+

Analytics

+ {dashboard ? ( + + {formatAnalyticsWindow(dashboard.window)} + + ) : null} + +
+
+
+ + +
+ onDaysChange(Number(value) as AnalyticsDays)} + /> +
+
+
+ {tabs} +
+ +
+
+
+ ); +} + +export type AnalyticsFreshness = "current" | "loading" | "previous"; + +/** "Updated 12s ago" beside a live dot; ticks on its own between refreshes. */ +function LiveStatus({ + generatedAt, + freshness, +}: { + generatedAt?: number; + freshness: AnalyticsFreshness; +}) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 5_000); + return () => window.clearInterval(timer); + }, []); + if (generatedAt === undefined) return null; + if (freshness !== "current") { + return ( + + + ); + } + + const seconds = Math.max(0, Math.round((now - generatedAt) / 1000)); + const age = + seconds < 5 + ? "just now" + : seconds < 60 + ? `${seconds}s ago` + : `${Math.round(seconds / 60)}m ago`; + return ( + + + ); +} + +function InfoIcon() { + return ( + + ); +} + +function ScopeHelp() { + return ( + + + + + +
Session scope
+
+ {ANALYTICS_SCOPES.map((option) => ( +
+
{ANALYTICS_SCOPE_LABELS[option]}
+
{ANALYTICS_SCOPE_DESCRIPTIONS[option]}
+
+ ))} +
+

+ Pull request metrics always include every source. +

+
+
+ ); +} + +function AboutThisData() { + return ( + + + + {/* Icon only on phones, where the tabs need the width. */} + About this data + + +
About this data
+
    +
  • Refreshes every {ANALYTICS_REFRESH_INTERVAL_MS / 1000} seconds.
  • +
  • Private sessions are excluded.
  • +
  • Legacy sessions are included and may show zero cost, PRs or duration.
  • +
  • Token totals start when token capture was enabled; older sessions count as zero.
  • +
  • + PR counts reflect pull requests created through the platform's built-in flow, + including automation-created ones. +
  • +
  • Sessions billed to a connected subscription report $0 cost.
  • +
+
+
+ ); +} diff --git a/packages/web/src/components/analytics/analytics-kpi-items.test.ts b/packages/web/src/components/analytics/analytics-kpi-items.test.ts new file mode 100644 index 0000000000..d51568b5cd --- /dev/null +++ b/packages/web/src/components/analytics/analytics-kpi-items.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { analyticsDashboard, breakdownEntry } from "@/lib/analytics.test-fixture"; +import { getCostKpis, getHeadlineKpiGroups, getPullRequestKpis } from "./analytics-kpi-items"; + +describe("getHeadlineKpiGroups", () => { + it("captions session numbers with the scope and pull request numbers with every source", () => { + const groups = getHeadlineKpiGroups(analyticsDashboard()); + expect(groups.map((group) => group.caption)).toEqual([ + "Human sessions", + "Pull requests · every source", + ]); + expect( + getHeadlineKpiGroups( + analyticsDashboard({ window: { ...analyticsDashboard().window, scope: "automation" } }) + )[0].caption + ).toBe("Automation sessions"); + }); + + it("leaves the PR funnel as the only source of PR counts", () => { + // summary.totalPrs counts PRs from sessions in scope; showing it beside the + // every-source funnel would put two different "PRs" numbers side by side. + const values = getHeadlineKpiGroups(analyticsDashboard()).flatMap((group) => + group.items.flatMap((item) => [item.value, String(item.detail)]) + ); + expect(values.join(" ")).not.toContain("99"); + }); + + it("summarizes sessions, spend and completion with a daily trend", () => { + const [sessions] = getHeadlineKpiGroups(analyticsDashboard()); + expect(sessions.items.map((item) => [item.label, item.value])).toEqual([ + ["Sessions", "12"], + ["Active users", "3"], + ["Spend", "$12.50"], + ["Completion rate", "70%"], + ]); + expect(sessions.items[0].trend).toHaveLength(8); + expect(sessions.items[3].detail).toBe("2 failed · 1 cancelled"); + }); + + it("counts merges during the window, the same population as its sparkline and merge time", () => { + const dashboard = analyticsDashboard(); + const [, pullRequests] = getHeadlineKpiGroups(dashboard); + const merged = pullRequests.items[0]; + expect(merged).toMatchObject({ label: "PRs merged", value: "5", detail: "Avg 30h to merge" }); + expect(merged.trend?.reduce((sum, count) => sum + count, 0)).toBe( + dashboard.pullRequests.mergedInWindow + ); + expect(pullRequests.items[1]).toMatchObject({ + label: "Cost per merged PR", + value: "$1.50", + detail: "PRs opened in range", + }); + }); + + it("divides attributed sessions by attributed people", () => { + const dashboard = analyticsDashboard(); + const [sessions] = getHeadlineKpiGroups({ + ...dashboard, + summary: { ...dashboard.summary, totalSessions: 103, activeUsers: 1 }, + breakdowns: { + ...dashboard.breakdowns, + user: { + entries: [ + breakdownEntry("user-zoe", { displayName: "Zoe", sessions: 3 }), + breakdownEntry("__unknown__", { displayName: "Unknown user", sessions: 100 }), + ], + }, + }, + }); + expect(sessions.items[1].detail).toBe("3 sessions per person"); + }); + + it("shows no completion rate before any session finishes", () => { + const dashboard = analyticsDashboard(); + const completion = getHeadlineKpiGroups({ + ...dashboard, + summary: { + ...dashboard.summary, + statusBreakdown: { + ...dashboard.summary.statusBreakdown, + completed: 0, + failed: 0, + cancelled: 0, + }, + }, + })[0].items[3]; + expect(completion.value).toBe("—"); + expect(completion.ratio).toBeNull(); + }); +}); + +describe("getCostKpis", () => { + it("adds private-session spend only for viewers who receive it", () => { + expect(getCostKpis(analyticsDashboard()).map((item) => item.label)).not.toContain( + "Private sessions" + ); + const dashboard = analyticsDashboard(); + const items = getCostKpis({ + ...dashboard, + summary: { ...dashboard.summary, privateSessionsCostUsd: 17.76 }, + }); + expect(items.at(-1)).toMatchObject({ label: "Private sessions", value: "$17.76" }); + }); + + it("reports the subscription share and token totals", () => { + const items = getCostKpis(analyticsDashboard()); + expect(items.find((item) => item.label === "Billed to subscriptions")?.value).toBe("33%"); + expect(items.find((item) => item.label === "Tokens")?.value).toBe("19.1K"); + }); +}); + +describe("getPullRequestKpis", () => { + it("describes the funnel, merge time and open inventory", () => { + expect( + getPullRequestKpis(analyticsDashboard()).map((item) => [item.label, item.value]) + ).toEqual([ + ["Opened", "10"], + ["Acceptance rate", "86%"], + ["Avg time to merge", "30h"], + ["Open now", "3"], + ["Cost per merged PR", "$1.50"], + ]); + }); +}); diff --git a/packages/web/src/components/analytics/analytics-kpi-items.ts b/packages/web/src/components/analytics/analytics-kpi-items.ts new file mode 100644 index 0000000000..20275db875 --- /dev/null +++ b/packages/web/src/components/analytics/analytics-kpi-items.ts @@ -0,0 +1,161 @@ +import { + getCacheHitRatio, + type AnalyticsDashboardResponse, +} from "@open-inspect/shared/types/analytics"; +import { + ANALYTICS_SCOPE_CAPTIONS, + ANALYTICS_UNKNOWN_USER_KEY, + formatAnalyticsCompactCount, + formatAnalyticsCost, + formatAnalyticsCount, + formatAnalyticsLongDuration, + formatAnalyticsRatio, + getCompletionRate, + getCostPerMergedPullRequest, + getDailyPullRequestCounts, + getDailySessionCounts, + getPullRequestAcceptanceRate, + getSubscriptionShare, +} from "@/lib/analytics"; +import type { AnalyticsKpiGroup, AnalyticsKpiItem } from "./analytics-kpis"; + +/** The overview's headline numbers, split by what the scope filter applies to. */ +export function getHeadlineKpiGroups(dashboard: AnalyticsDashboardResponse): AnalyticsKpiGroup[] { + const { summary, pullRequests, window } = dashboard; + const status = summary.statusBreakdown; + const completion = getCompletionRate(status); + // Divide attributed sessions by the people they belong to; unattributed sessions + // have no person, so counting them would inflate everyone's share. + const people = dashboard.breakdowns.user.entries.filter( + (entry) => entry.key !== ANALYTICS_UNKNOWN_USER_KEY + ); + const attributedSessions = people.reduce((sum, entry) => sum + entry.sessions, 0); + + return [ + { + caption: ANALYTICS_SCOPE_CAPTIONS[window.scope], + items: [ + { + label: "Sessions", + value: formatAnalyticsCount(summary.totalSessions), + detail: `About ${formatAnalyticsCount(Math.round(summary.totalSessions / window.days))} a day`, + trend: getDailySessionCounts(dashboard).map((point) => point.sessions), + }, + { + label: "Active users", + value: formatAnalyticsCount(summary.activeUsers), + detail: + people.length > 0 + ? `${formatAnalyticsCount(Math.round(attributedSessions / people.length))} sessions per person` + : "No attributed users", + }, + { + label: "Spend", + value: formatAnalyticsCost(summary.totalCost), + detail: `${formatAnalyticsCost(summary.avgCost)} per session`, + }, + { + label: "Completion rate", + value: formatAnalyticsRatio(completion), + detail: `${formatAnalyticsCount(status.failed)} failed · ${formatAnalyticsCount(status.cancelled)} cancelled`, + ratio: completion, + }, + ], + }, + { + caption: "Pull requests · every source", + items: [ + { + // Merges during the window: the same population as the daily-merges + // sparkline and the average time to merge. + label: "PRs merged", + value: formatAnalyticsCount(pullRequests.mergedInWindow), + detail: + pullRequests.avgTimeToMergeMs === null + ? "None merged in range" + : `Avg ${formatAnalyticsLongDuration(pullRequests.avgTimeToMergeMs)} to merge`, + trend: getDailyPullRequestCounts(dashboard).map((point) => point.merged), + }, + { + label: "Cost per merged PR", + value: formatAnalyticsCost( + getCostPerMergedPullRequest(pullRequests.prSessionCost, pullRequests.funnel.merged) + ), + detail: "PRs opened in range", + }, + ], + }, + ]; +} + +export function getCostKpis(dashboard: AnalyticsDashboardResponse): AnalyticsKpiItem[] { + const { summary } = dashboard; + const cacheHitRatio = getCacheHitRatio(summary); + const items: AnalyticsKpiItem[] = [ + { + label: "Spend", + value: formatAnalyticsCost(summary.totalCost), + detail: `${formatAnalyticsCost(summary.avgCost)} per session`, + }, + { + label: "Cache hit ratio", + value: formatAnalyticsRatio(cacheHitRatio), + detail: "Of input read from cache", + ratio: cacheHitRatio, + }, + { + label: "Billed to subscriptions", + value: formatAnalyticsRatio(getSubscriptionShare(dashboard.breakdowns.provider.entries)), + detail: "Of sessions; they report $0", + }, + { + label: "Tokens", + value: formatAnalyticsCompactCount(summary.inputTokens + summary.outputTokens), + detail: `${formatAnalyticsCompactCount(summary.inputTokens)} in · ${formatAnalyticsCompactCount(summary.outputTokens)} out`, + }, + ]; + // Only owners and administrators receive private-session cost. + if (summary.privateSessionsCostUsd !== null) { + items.push({ + label: "Private sessions", + value: formatAnalyticsCost(summary.privateSessionsCostUsd), + detail: "Not included in spend", + }); + } + return items; +} + +export function getPullRequestKpis(dashboard: AnalyticsDashboardResponse): AnalyticsKpiItem[] { + const { funnel, openInventory, avgTimeToMergeMs, mergedInWindow, prSessionCost } = + dashboard.pullRequests; + return [ + { + label: "Opened", + value: formatAnalyticsCount(funnel.created), + detail: `In the last ${dashboard.window.days} days`, + }, + { + label: "Acceptance rate", + value: formatAnalyticsRatio(getPullRequestAcceptanceRate(funnel)), + detail: `${formatAnalyticsCount(funnel.merged)} merged · ${formatAnalyticsCount(funnel.closed)} closed unmerged`, + }, + { + label: "Avg time to merge", + value: avgTimeToMergeMs === null ? "—" : formatAnalyticsLongDuration(avgTimeToMergeMs), + detail: `${formatAnalyticsCount(mergedInWindow)} merged in range`, + }, + { + label: "Open now", + value: formatAnalyticsCount(openInventory.total), + detail: + openInventory.avgAgeMs === null + ? "Nothing waiting on review" + : `Avg age ${formatAnalyticsLongDuration(openInventory.avgAgeMs)}`, + }, + { + label: "Cost per merged PR", + value: formatAnalyticsCost(getCostPerMergedPullRequest(prSessionCost, funnel.merged)), + detail: `${formatAnalyticsCost(prSessionCost)} across PR sessions`, + }, + ]; +} diff --git a/packages/web/src/components/analytics/analytics-kpis.tsx b/packages/web/src/components/analytics/analytics-kpis.tsx new file mode 100644 index 0000000000..0013640d2b --- /dev/null +++ b/packages/web/src/components/analytics/analytics-kpis.tsx @@ -0,0 +1,139 @@ +import type { CSSProperties, ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export interface AnalyticsKpiItem { + label: string; + value: string; + detail?: ReactNode; + /** Daily values drawn as a sparkline under the figure. */ + trend?: number[]; + /** A 0–1 ratio drawn as a meter under the figure. */ + ratio?: number | null; +} + +export interface AnalyticsKpiGroup { + caption: string; + items: AnalyticsKpiItem[]; +} + +const COLUMNS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-2 md:grid-cols-4", + 5: "grid-cols-2 md:grid-cols-3 xl:grid-cols-5", + 6: "grid-cols-2 md:grid-cols-3 xl:grid-cols-6", +}; + +/** Stat cells in one frame, divided by hairlines rather than drawn as separate cards. */ +export function AnalyticsKpiStrip({ items }: { items: AnalyticsKpiItem[] }) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +/** + * Strips grouped by what they describe. Captions such as "Human sessions" and + * "Pull requests · every source" show which numbers the scope filter applies to. + */ +export function AnalyticsKpiGroups({ groups }: { groups: AnalyticsKpiGroup[] }) { + return ( +
`${group.items.length}fr`).join(" "), + } as CSSProperties + } + > + {groups.map((group) => ( +
+
{group.caption}
+ +
+ ))} +
+ ); +} + +function AnalyticsKpi({ label, value, detail, trend, ratio }: AnalyticsKpiItem) { + return ( +
+
{label}
+
{value}
+ {detail ? ( +
{detail}
+ ) : null} + {trend ? : null} + {ratio !== undefined && ratio !== null ? ( + + ) : null} +
+ ); +} + +/** A trend line with a soft wash; decorative, since the figure beside it carries the value. */ +export function AnalyticsSparkline({ + values, + height = 28, + className, +}: { + values: number[]; + height?: number; + className?: string; +}) { + if (values.length < 2) return