diff --git a/lib/chat/runs/__tests__/provisionRunSession.test.ts b/lib/chat/runs/__tests__/provisionRunSession.test.ts index 3dd3a13b..e651ab3b 100644 --- a/lib/chat/runs/__tests__/provisionRunSession.test.ts +++ b/lib/chat/runs/__tests__/provisionRunSession.test.ts @@ -23,7 +23,7 @@ vi.mock("@/lib/github/getServiceGithubToken", () => ({ vi.mock("@/lib/sandbox/markSessionSandboxActive", () => ({ markSessionSandboxActive: vi.fn(), })); -vi.mock("@/lib/skills/discoverSkills", () => ({ discoverSkills: vi.fn(async () => []) })); +vi.mock("@/lib/skills/discoverSkills", () => ({ discoverSkills: vi.fn() })); vi.mock("@/lib/skills/getSandboxSkillDirectories", () => ({ getSandboxSkillDirectories: vi.fn(async () => ["/skills"]), })); @@ -42,6 +42,7 @@ const sandbox = { getState: () => ({ type: "vercel" }), workingDirectory: "/work", }; +const platformApiSkill = { name: "recoup-platform-api-access" }; describe("provisionRunSession", () => { beforeEach(() => { @@ -53,6 +54,8 @@ describe("provisionRunSession", () => { } as never); vi.mocked(connectSandbox).mockResolvedValue(sandbox as never); vi.mocked(markSessionSandboxActive).mockResolvedValue(updated as never); + // Default: the platform API-access skill is present after discovery. + vi.mocked(discoverSkills).mockResolvedValue([platformApiSkill] as never); }); it("installs global skills into the sandbox before discovering them", async () => { @@ -69,7 +72,7 @@ describe("provisionRunSession", () => { expect(installOrder).toBeLessThan(discoverOrder); }); - it("still completes the run when skill install fails (best-effort)", async () => { + it("still completes the run when skill install fails but discovery finds the skill", async () => { vi.mocked(installSessionGlobalSkills).mockRejectedValueOnce(new Error("install boom")); const result = await provisionRunSession({ accountId: "account-1", title: "t" }); @@ -77,4 +80,13 @@ describe("provisionRunSession", () => { expect(result.session).toEqual(updated); expect(discoverSkills).toHaveBeenCalled(); }); + + it("aborts the run when the platform API-access skill is missing after discovery", async () => { + vi.mocked(discoverSkills).mockResolvedValue([] as never); + + // Fail closed: better a missed run than an ungrounded (fabricated) one (chat#1822). + await expect(provisionRunSession({ accountId: "account-1", title: "t" })).rejects.toThrow( + /recoup-platform-api-access/, + ); + }); }); diff --git a/lib/chat/runs/provisionRunSession.ts b/lib/chat/runs/provisionRunSession.ts index df1b054f..b3b2dd10 100644 --- a/lib/chat/runs/provisionRunSession.ts +++ b/lib/chat/runs/provisionRunSession.ts @@ -15,6 +15,10 @@ import type { SkillMetadata } from "@/lib/skills/skillTypes"; const SANDBOX_TIMEOUT_MS = ms("30m"); +// The platform API-access skill the agent needs to reach the Recoup API for +// account data. If it's missing post-provision the run is aborted (chat#1822). +const REQUIRED_PLATFORM_API_SKILL = "recoup-platform-api-access"; + export type ProvisionedRunSession = { session: Tables<"sessions">; chat: Tables<"chats">; @@ -101,6 +105,17 @@ export async function provisionRunSession({ console.error("[provisionRunSession] skill discovery failed; using defaults:", error); } + // Fail closed: if the platform API-access skill isn't available after install + // + discovery, abort instead of running an agent that can't reach the Recoup + // API — which guesses endpoints and fabricates ungrounded data (chat#1822). + // A missed run is recoverable; a fabricated report sent to a customer is not. + // The caller maps this throw to a 5xx and revokes any minted ephemeral key. + if (!skills.some(skill => skill.name === REQUIRED_PLATFORM_API_SKILL)) { + throw new Error( + `[provisionRunSession] required skill '${REQUIRED_PLATFORM_API_SKILL}' unavailable after install/discovery — aborting to avoid an ungrounded run`, + ); + } + return { session: updated, chat,