diff --git a/apps/api/src/routes/hooks.ts b/apps/api/src/routes/hooks.ts index c6ce3774..bec3ca99 100644 --- a/apps/api/src/routes/hooks.ts +++ b/apps/api/src/routes/hooks.ts @@ -159,9 +159,9 @@ export async function hookRoutes(rawApp: FastifyInstance) { } if (trigger.targetType === "persistent_agent") { - const { getPersistentAgent, wakeAgent, buildSenderId } = + const { getPersistentAgentUnscoped, wakeAgent, buildSenderId } = await import("../services/persistent-agent-service.js"); - const agent = await getPersistentAgent(trigger.targetId); + const agent = await getPersistentAgentUnscoped(trigger.targetId); if (!agent || !agent.enabled) { return reply.status(404).send({ error: "Target persistent agent not found or disabled" }); } diff --git a/apps/api/src/routes/persistent-agents.test.ts b/apps/api/src/routes/persistent-agents.test.ts index 59e5f83b..c669afa2 100644 --- a/apps/api/src/routes/persistent-agents.test.ts +++ b/apps/api/src/routes/persistent-agents.test.ts @@ -5,31 +5,61 @@ import { buildRouteTestApp } from "../test-utils/build-route-test-app.js"; // ─── Mocks ─── const mockListPersistentAgents = vi.fn(); -const mockGetPersistentAgent = vi.fn(); +const mockGetPersistentAgentScoped = vi.fn(); const mockListInboxSummary = vi.fn(); const mockGetPersistentAgentStats = vi.fn(); +const mockUpdatePersistentAgent = vi.fn(); +const mockDeletePersistentAgent = vi.fn(); +const mockSetControlIntent = vi.fn(); +const mockWakeAgent = vi.fn(); +const mockListRecentMessages = vi.fn(); +const mockListPersistentAgentTurns = vi.fn(); +const mockGetPersistentAgentTurn = vi.fn(); +const mockListTurnLogs = vi.fn(); vi.mock("../services/persistent-agent-service.js", () => ({ listPersistentAgents: (...args: unknown[]) => mockListPersistentAgents(...args), - getPersistentAgent: (...args: unknown[]) => mockGetPersistentAgent(...args), + getPersistentAgentScoped: (...args: unknown[]) => mockGetPersistentAgentScoped(...args), + // Unscoped getter exists for workers/reconciler/internal callers; routes must + // never reach it. Present here only so the namespace import surface is complete. + getPersistentAgentUnscoped: vi.fn(), listInboxSummary: (...args: unknown[]) => mockListInboxSummary(...args), getPersistentAgentStats: (...args: unknown[]) => mockGetPersistentAgentStats(...args), - // Stubs for the rest of the namespace import surface createPersistentAgent: vi.fn(), - updatePersistentAgent: vi.fn(), - deletePersistentAgent: vi.fn(), - setControlIntent: vi.fn(), - wakeAgent: vi.fn(), - listRecentMessages: vi.fn(), - listPersistentAgentTurns: vi.fn(), - getPersistentAgentTurn: vi.fn(), - listTurnLogs: vi.fn(), + updatePersistentAgent: (...args: unknown[]) => mockUpdatePersistentAgent(...args), + deletePersistentAgent: (...args: unknown[]) => mockDeletePersistentAgent(...args), + setControlIntent: (...args: unknown[]) => mockSetControlIntent(...args), + wakeAgent: (...args: unknown[]) => mockWakeAgent(...args), + listRecentMessages: (...args: unknown[]) => mockListRecentMessages(...args), + listPersistentAgentTurns: (...args: unknown[]) => mockListPersistentAgentTurns(...args), + getPersistentAgentTurn: (...args: unknown[]) => mockGetPersistentAgentTurn(...args), + listTurnLogs: (...args: unknown[]) => mockListTurnLogs(...args), })); vi.mock("../services/optio-action-service.js", () => ({ logAction: vi.fn().mockResolvedValue(undefined), })); +// Trigger routes reach into the DB directly via dynamic import. Mock the client +// and schema so the same-workspace happy path can complete; the cross-workspace +// tests short-circuit at the workspace guard and never touch these. +const mockTriggerDeleteReturning = vi.fn(); +vi.mock("../db/client.js", () => ({ + db: { + select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }), + insert: () => ({ values: () => ({ returning: () => Promise.resolve([{ id: "trigger-1" }]) }) }), + delete: () => ({ where: () => ({ returning: () => mockTriggerDeleteReturning() }) }), + }, +})); + +vi.mock("../db/schema.js", () => ({ + workflowTriggers: { id: "id", targetType: "targetType", targetId: "targetId" }, +})); + +vi.mock("../services/reconcile-queue.js", () => ({ + enqueueReconcile: vi.fn().mockResolvedValue(undefined), +})); + import { persistentAgentRoutes } from "./persistent-agents.js"; async function buildTestApp(): Promise { @@ -89,6 +119,244 @@ describe("GET /api/persistent-agents/stats", () => { expect(res.statusCode).toBe(200); expect(mockGetPersistentAgentStats).toHaveBeenCalledTimes(1); - expect(mockGetPersistentAgent).not.toHaveBeenCalled(); + expect(mockGetPersistentAgentScoped).not.toHaveBeenCalled(); + }); +}); + +// ─── Cross-tenant workspace scoping ─── +// +// Every id-addressed handler must resolve the agent through the +// workspace-scoped getter. A caller whose workspace differs from the agent's +// must get 404 (not 403 — no cross-tenant existence oracle) and must not +// mutate, wake, or read the foreign agent. + +describe("persistent-agent routes enforce workspace scoping", () => { + // Agent lives in ws-1. The default test user (from buildRouteTestApp) is also + // in ws-1; the "attacker" app below is a member of ws-2. + const AGENT_ID = "11111111-1111-1111-1111-111111111111"; + const TURN_ID = "22222222-2222-2222-2222-222222222222"; + const TRIGGER_ID = "33333333-3333-3333-3333-333333333333"; + const AGENT = { + id: AGENT_ID, + workspaceId: "ws-1", + slug: "forge", + name: "Forge", + enabled: true, + }; + + // The mocked scoped getter mirrors the real SQL predicate: it returns the + // agent only when the requested workspace matches the agent's. + function installScopedGetter() { + mockGetPersistentAgentScoped.mockImplementation( + async (id: string, workspaceId: string | null) => + id === AGENT.id && workspaceId === AGENT.workspaceId ? AGENT : null, + ); + } + + let sameWsApp: FastifyInstance; + let foreignApp: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + installScopedGetter(); + // Same admin role in both so the requireRole("member") guard passes and we + // isolate the workspace check (403 would mask a scoping bug). + sameWsApp = await buildRouteTestApp(persistentAgentRoutes, { + user: { id: "user-1", workspaceId: "ws-1", workspaceRole: "admin" }, + }); + foreignApp = await buildRouteTestApp(persistentAgentRoutes, { + user: { id: "user-2", workspaceId: "ws-2", workspaceRole: "admin" }, + }); + }); + + describe("GET /:id (detail)", () => { + it("404s for a foreign workspace and forwards the caller's workspace to the scoped getter", async () => { + const res = await foreignApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}`, + }); + expect(res.statusCode).toBe(404); + expect(mockGetPersistentAgentScoped).toHaveBeenCalledWith(AGENT_ID, "ws-2"); + expect(mockListInboxSummary).not.toHaveBeenCalled(); + }); + + it("returns the agent for a same-workspace caller", async () => { + mockListInboxSummary.mockResolvedValue({ pending: 0, oldest: null }); + const res = await sameWsApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}`, + }); + expect(res.statusCode).toBe(200); + expect(res.json().agent.id).toBe(AGENT_ID); + expect(mockGetPersistentAgentScoped).toHaveBeenCalledWith(AGENT_ID, "ws-1"); + }); + }); + + describe("PATCH /:id", () => { + it("404s for a foreign workspace and does not update", async () => { + const res = await foreignApp.inject({ + method: "PATCH", + url: `/api/persistent-agents/${AGENT_ID}`, + payload: { name: "Pwned" }, + }); + expect(res.statusCode).toBe(404); + expect(mockUpdatePersistentAgent).not.toHaveBeenCalled(); + }); + + it("updates for a same-workspace caller and passes the workspace to the scoped update", async () => { + mockUpdatePersistentAgent.mockResolvedValue({ ...AGENT, name: "Renamed" }); + const res = await sameWsApp.inject({ + method: "PATCH", + url: `/api/persistent-agents/${AGENT_ID}`, + payload: { name: "Renamed" }, + }); + expect(res.statusCode).toBe(200); + expect(mockUpdatePersistentAgent).toHaveBeenCalledWith( + AGENT_ID, + expect.objectContaining({ name: "Renamed" }), + "ws-1", + ); + }); + }); + + describe("DELETE /:id", () => { + it("404s for a foreign workspace and does not delete", async () => { + const res = await foreignApp.inject({ + method: "DELETE", + url: `/api/persistent-agents/${AGENT_ID}`, + }); + expect(res.statusCode).toBe(404); + expect(mockDeletePersistentAgent).not.toHaveBeenCalled(); + }); + + it("deletes for a same-workspace caller and passes the workspace to the scoped delete", async () => { + mockDeletePersistentAgent.mockResolvedValue(true); + const res = await sameWsApp.inject({ + method: "DELETE", + url: `/api/persistent-agents/${AGENT_ID}`, + }); + expect(res.statusCode).toBe(204); + expect(mockDeletePersistentAgent).toHaveBeenCalledWith(AGENT_ID, "ws-1"); + }); + }); + + describe("POST /:id/messages", () => { + it("404s for a foreign workspace and does not wake the agent", async () => { + const res = await foreignApp.inject({ + method: "POST", + url: `/api/persistent-agents/${AGENT_ID}/messages`, + payload: { body: "run this in the victim's pod" }, + }); + expect(res.statusCode).toBe(404); + expect(mockWakeAgent).not.toHaveBeenCalled(); + }); + + it("wakes the agent for a same-workspace caller", async () => { + mockWakeAgent.mockResolvedValue(undefined); + const res = await sameWsApp.inject({ + method: "POST", + url: `/api/persistent-agents/${AGENT_ID}/messages`, + payload: { body: "hello" }, + }); + expect(res.statusCode).toBe(202); + expect(mockWakeAgent).toHaveBeenCalledTimes(1); + }); + }); + + describe("GET /:id/turns", () => { + it("404s for a foreign workspace and does not list turns", async () => { + const res = await foreignApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}/turns`, + }); + expect(res.statusCode).toBe(404); + expect(mockListPersistentAgentTurns).not.toHaveBeenCalled(); + }); + + it("lists turns for a same-workspace caller", async () => { + mockListPersistentAgentTurns.mockResolvedValue([]); + const res = await sameWsApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}/turns`, + }); + expect(res.statusCode).toBe(200); + expect(mockListPersistentAgentTurns).toHaveBeenCalled(); + }); + }); + + describe("GET /:id/turns/:turnId", () => { + it("404s for a foreign workspace and does not read the turn", async () => { + const res = await foreignApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}/turns/${TURN_ID}`, + }); + expect(res.statusCode).toBe(404); + expect(mockGetPersistentAgentTurn).not.toHaveBeenCalled(); + }); + + it("404s when the turn belongs to a different agent", async () => { + mockGetPersistentAgentTurn.mockResolvedValue({ id: TURN_ID, agentId: "some-other-agent" }); + const res = await sameWsApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}/turns/${TURN_ID}`, + }); + expect(res.statusCode).toBe(404); + expect(mockListTurnLogs).not.toHaveBeenCalled(); + }); + + it("returns the turn for a same-workspace caller", async () => { + mockGetPersistentAgentTurn.mockResolvedValue({ id: TURN_ID, agentId: AGENT_ID }); + mockListTurnLogs.mockResolvedValue([]); + const res = await sameWsApp.inject({ + method: "GET", + url: `/api/persistent-agents/${AGENT_ID}/turns/${TURN_ID}`, + }); + expect(res.statusCode).toBe(200); + expect(res.json().turn.id).toBe(TURN_ID); + }); + }); + + describe("DELETE /:id/triggers/:triggerId", () => { + it("404s for a foreign workspace and does not delete the trigger", async () => { + const res = await foreignApp.inject({ + method: "DELETE", + url: `/api/persistent-agents/${AGENT_ID}/triggers/${TRIGGER_ID}`, + }); + expect(res.statusCode).toBe(404); + expect(mockTriggerDeleteReturning).not.toHaveBeenCalled(); + }); + + it("deletes the trigger for a same-workspace caller", async () => { + mockTriggerDeleteReturning.mockResolvedValue([{ id: TRIGGER_ID }]); + const res = await sameWsApp.inject({ + method: "DELETE", + url: `/api/persistent-agents/${AGENT_ID}/triggers/${TRIGGER_ID}`, + }); + expect(res.statusCode).toBe(204); + expect(mockTriggerDeleteReturning).toHaveBeenCalledTimes(1); + }); + }); + + describe("POST /:id/control", () => { + it("404s for a foreign workspace and does not set a control intent", async () => { + const res = await foreignApp.inject({ + method: "POST", + url: `/api/persistent-agents/${AGENT_ID}/control`, + payload: { intent: "archive" }, + }); + expect(res.statusCode).toBe(404); + expect(mockSetControlIntent).not.toHaveBeenCalled(); + }); + + it("sets the control intent for a same-workspace caller and passes the workspace", async () => { + mockSetControlIntent.mockResolvedValue(AGENT); + const res = await sameWsApp.inject({ + method: "POST", + url: `/api/persistent-agents/${AGENT_ID}/control`, + payload: { intent: "pause" }, + }); + expect(res.statusCode).toBe(200); + expect(mockSetControlIntent).toHaveBeenCalledWith(AGENT_ID, "pause", "ws-1"); + }); }); }); diff --git a/apps/api/src/routes/persistent-agents.ts b/apps/api/src/routes/persistent-agents.ts index 111b6efc..57f22698 100644 --- a/apps/api/src/routes/persistent-agents.ts +++ b/apps/api/src/routes/persistent-agents.ts @@ -3,7 +3,7 @@ // Mirrors the workflow routes layout. The polymorphic /api/tasks layer // gains type='persistent_agent' resolution in tasks-unified.ts. -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; import * as paService from "../services/persistent-agent-service.js"; @@ -15,6 +15,27 @@ import { type PersistentAgentMessageSenderType, } from "@optio/shared"; import { logAction } from "../services/optio-action-service.js"; +import { requireRole } from "../plugins/auth.js"; + +/** + * Resolve an agent that belongs to the caller's workspace, or send a 404 and + * return null. Every `/:id` handler funnels through this so a caller can never + * read, mutate, or wake another tenant's persistent agent by primary key. + * + * We deliberately return 404 (not 403) for foreign agents so the endpoint does + * not become a cross-tenant existence oracle. Mirrors the workspace guard in + * `routes/tasks.ts`. When auth is disabled (local dev) `req.user` is undefined, + * so `workspaceId` is null and the scoped lookup matches the null-workspace + * rows that local dev creates — behavior is preserved. + */ +async function requireAgent(req: FastifyRequest, reply: FastifyReply, id: string) { + const agent = await paService.getPersistentAgentScoped(id, req.user?.workspaceId ?? null); + if (!agent) { + reply.code(404).send({ error: "Not found" }); + return null; + } + return agent; +} const podLifecycleSchema = z.enum(["always-on", "sticky", "on-demand"]); @@ -131,8 +152,8 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { }, async (req, reply) => { const { id } = req.params; - const agent = await paService.getPersistentAgent(id); - if (!agent) return reply.code(404).send({ error: "Not found" }); + const agent = await requireAgent(req, reply, id); + if (!agent) return; const inbox = await paService.listInboxSummary(id); reply.send({ agent, inbox }); }, @@ -142,6 +163,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.post( "/api/persistent-agents", { + preHandler: [requireRole("member")], schema: { operationId: "createPersistentAgent", summary: "Create a persistent agent", @@ -191,6 +213,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.patch( "/api/persistent-agents/:id", { + preHandler: [requireRole("member")], schema: { operationId: "updatePersistentAgent", summary: "Update a persistent agent", @@ -202,10 +225,17 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { async (req, reply) => { const { id } = req.params; const body = req.body; - const updated = await paService.updatePersistentAgent(id, { - ...body, - podLifecycle: body.podLifecycle as PersistentAgentPodLifecycle | undefined, - }); + const workspaceId = req.user?.workspaceId ?? null; + const existing = await requireAgent(req, reply, id); + if (!existing) return; + const updated = await paService.updatePersistentAgent( + id, + { + ...body, + podLifecycle: body.podLifecycle as PersistentAgentPodLifecycle | undefined, + }, + workspaceId, + ); if (!updated) return reply.code(404).send({ error: "Not found" }); reply.send({ agent: updated }); }, @@ -215,6 +245,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.delete( "/api/persistent-agents/:id", { + preHandler: [requireRole("member")], schema: { operationId: "deletePersistentAgent", summary: "Delete a persistent agent", @@ -224,7 +255,10 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { }, async (req, reply) => { const { id } = req.params; - const ok = await paService.deletePersistentAgent(id); + const workspaceId = req.user?.workspaceId ?? null; + const existing = await requireAgent(req, reply, id); + if (!existing) return; + const ok = await paService.deletePersistentAgent(id, workspaceId); if (!ok) return reply.code(404).send({ error: "Not found" }); reply.code(204).send(); }, @@ -234,6 +268,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.post( "/api/persistent-agents/:id/messages", { + preHandler: [requireRole("member")], schema: { operationId: "sendPersistentAgentMessage", summary: "Send a message to a persistent agent", @@ -248,8 +283,8 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { async (req, reply) => { const { id } = req.params; const body = req.body; - const agent = await paService.getPersistentAgent(id); - if (!agent) return reply.code(404).send({ error: "Not found" }); + const agent = await requireAgent(req, reply, id); + if (!agent) return; const senderType: PersistentAgentMessageSenderType = body.senderType ?? "user"; const senderId = @@ -297,6 +332,8 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { async (req, reply) => { const { id } = req.params; const { limit } = req.query; + const agent = await requireAgent(req, reply, id); + if (!agent) return; const messages = await paService.listRecentMessages(id, limit ?? 100); reply.send({ messages }); }, @@ -317,6 +354,8 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { async (req, reply) => { const { id } = req.params; const { limit } = req.query; + const agent = await requireAgent(req, reply, id); + if (!agent) return; const turns = await paService.listPersistentAgentTurns(id, limit ?? 50); reply.send({ turns }); }, @@ -334,9 +373,13 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { }, }, async (req, reply) => { - const { turnId } = req.params; + const { id, turnId } = req.params; + const agent = await requireAgent(req, reply, id); + if (!agent) return; const turn = await paService.getPersistentAgentTurn(turnId); - if (!turn) return reply.code(404).send({ error: "Not found" }); + // Also verify the turn belongs to this agent so a valid-for-caller id + // cannot be paired with another agent's turnId. + if (!turn || turn.agentId !== id) return reply.code(404).send({ error: "Not found" }); const logs = await paService.listTurnLogs(turnId); reply.send({ turn, logs }); }, @@ -356,8 +399,8 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { }, async (req, reply) => { const { id } = req.params; - const agent = await paService.getPersistentAgent(id); - if (!agent) return reply.code(404).send({ error: "Not found" }); + const agent = await requireAgent(req, reply, id); + if (!agent) return; const { db } = await import("../db/client.js"); const { workflowTriggers } = await import("../db/schema.js"); const { and, eq } = await import("drizzle-orm"); @@ -377,6 +420,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.post( "/api/persistent-agents/:id/triggers", { + preHandler: [requireRole("member")], schema: { operationId: "createPersistentAgentTrigger", summary: "Attach a schedule/webhook/manual trigger to a persistent agent", @@ -397,8 +441,8 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { async (req, reply) => { const { id } = req.params; const body = req.body; - const agent = await paService.getPersistentAgent(id); - if (!agent) return reply.code(404).send({ error: "Not found" }); + const agent = await requireAgent(req, reply, id); + if (!agent) return; // Validate config shape per trigger type — same rules as workflow triggers. if (body.type === "schedule") { @@ -450,6 +494,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.delete( "/api/persistent-agents/:id/triggers/:triggerId", { + preHandler: [requireRole("member")], schema: { operationId: "deletePersistentAgentTrigger", summary: "Delete a trigger from a persistent agent", @@ -458,13 +503,23 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { }, }, async (req, reply) => { - const { triggerId } = req.params; + const { id, triggerId } = req.params; + const agent = await requireAgent(req, reply, id); + if (!agent) return; const { db } = await import("../db/client.js"); const { workflowTriggers } = await import("../db/schema.js"); - const { eq } = await import("drizzle-orm"); + const { and, eq } = await import("drizzle-orm"); + // Scope the delete to this agent's triggers so a trigger id from another + // agent (or another tenant) can't be removed via a valid-for-caller :id. const deleted = await db .delete(workflowTriggers) - .where(eq(workflowTriggers.id, triggerId)) + .where( + and( + eq(workflowTriggers.id, triggerId), + eq(workflowTriggers.targetType, "persistent_agent"), + eq(workflowTriggers.targetId, id), + ), + ) .returning({ id: workflowTriggers.id }); if (deleted.length === 0) return reply.code(404).send({ error: "Not found" }); reply.code(204).send(); @@ -474,6 +529,7 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { app.post( "/api/persistent-agents/:id/control", { + preHandler: [requireRole("member")], schema: { operationId: "controlPersistentAgent", summary: "Set a control intent (pause/resume/archive/restart)", @@ -485,9 +541,10 @@ export async function persistentAgentRoutes(rawApp: FastifyInstance) { async (req, reply) => { const { id } = req.params; const { intent } = req.body; - const agent = await paService.getPersistentAgent(id); - if (!agent) return reply.code(404).send({ error: "Not found" }); - await paService.setControlIntent(id, intent); + const workspaceId = req.user?.workspaceId ?? null; + const agent = await requireAgent(req, reply, id); + if (!agent) return; + await paService.setControlIntent(id, intent, workspaceId); // Wake the reconciler so it observes the intent immediately. const { enqueueReconcile } = await import("../services/reconcile-queue.js"); await enqueueReconcile( diff --git a/apps/api/src/services/persistent-agent-service.ts b/apps/api/src/services/persistent-agent-service.ts index e0a6960e..d08fc93d 100644 --- a/apps/api/src/services/persistent-agent-service.ts +++ b/apps/api/src/services/persistent-agent-service.ts @@ -29,6 +29,18 @@ import { logger } from "../logger.js"; // ── CRUD ──────────────────────────────────────────────────────────────────── +/** + * Workspace predicate for `persistent_agents` queries. `null` scopes to the + * default (workspace-less) tenant, matching the local-dev / auth-disabled + * world where every agent row has a null `workspace_id`. Mirrors the pattern + * used by `getPersistentAgentBySlug` and `getPersistentAgentStats`. + */ +function wsPredicate(workspaceId: string | null) { + return workspaceId === null + ? isNull(persistentAgents.workspaceId) + : eq(persistentAgents.workspaceId, workspaceId); +} + export async function listPersistentAgents(workspaceId?: string | null) { const baseQuery = db.select().from(persistentAgents).orderBy(desc(persistentAgents.updatedAt)); if (workspaceId !== undefined) { @@ -41,11 +53,31 @@ export async function listPersistentAgents(workspaceId?: string | null) { return baseQuery; } -export async function getPersistentAgent(id: string) { +/** + * Unscoped lookup by primary key. Callers that legitimately operate outside a + * user's workspace context — workers, the reconciler, trigger/webhook dispatch, + * and internal service self-calls — use this. **User-facing HTTP routes must + * NOT use this**; they resolve agents through `getPersistentAgentScoped` so a + * caller can never reach another tenant's agent by id. + */ +export async function getPersistentAgentUnscoped(id: string) { const [row] = await db.select().from(persistentAgents).where(eq(persistentAgents.id, id)); return row ?? null; } +/** + * Workspace-scoped lookup by primary key. Returns the agent only when it lives + * in `workspaceId`; a foreign or missing id resolves to `null` so routes can + * 404 without leaking cross-tenant existence. + */ +export async function getPersistentAgentScoped(id: string, workspaceId: string | null) { + const [row] = await db + .select() + .from(persistentAgents) + .where(and(eq(persistentAgents.id, id), wsPredicate(workspaceId))); + return row ?? null; +} + export async function getPersistentAgentBySlug(workspaceId: string | null, slug: string) { const conditions = [eq(persistentAgents.slug, slug)]; if (workspaceId === null) { @@ -129,30 +161,41 @@ export interface UpdatePersistentAgentInput { enabled?: boolean; } -export async function updatePersistentAgent(id: string, input: UpdatePersistentAgentInput) { +export async function updatePersistentAgent( + id: string, + input: UpdatePersistentAgentInput, + workspaceId: string | null, +) { const [row] = await db .update(persistentAgents) .set({ ...input, updatedAt: new Date() }) - .where(eq(persistentAgents.id, id)) + .where(and(eq(persistentAgents.id, id), wsPredicate(workspaceId))) .returning(); return row ?? null; } -export async function deletePersistentAgent(id: string): Promise { +export async function deletePersistentAgent( + id: string, + workspaceId: string | null, +): Promise { const deleted = await db .delete(persistentAgents) - .where(eq(persistentAgents.id, id)) + .where(and(eq(persistentAgents.id, id), wsPredicate(workspaceId))) .returning({ id: persistentAgents.id }); return deleted.length > 0; } // ── Control intent ────────────────────────────────────────────────────────── -export async function setControlIntent(id: string, intent: PersistentAgentControlIntent | null) { +export async function setControlIntent( + id: string, + intent: PersistentAgentControlIntent | null, + workspaceId: string | null, +) { const [row] = await db .update(persistentAgents) .set({ controlIntent: intent, updatedAt: new Date() }) - .where(eq(persistentAgents.id, id)) + .where(and(eq(persistentAgents.id, id), wsPredicate(workspaceId))) .returning(); return row ?? null; } @@ -258,7 +301,7 @@ export interface ReceiveMessageInput { } export async function receivePersistentAgentMessage(input: ReceiveMessageInput) { - const agent = await getPersistentAgent(input.agentId); + const agent = await getPersistentAgentUnscoped(input.agentId); if (!agent) throw new Error(`Persistent agent ${input.agentId} not found`); const [msg] = await db @@ -368,7 +411,7 @@ export interface CreateTurnInput { } export async function createPersistentAgentTurn(input: CreateTurnInput) { - const agent = await getPersistentAgent(input.agentId); + const agent = await getPersistentAgentUnscoped(input.agentId); if (!agent) throw new Error(`Persistent agent ${input.agentId} not found`); // Determine next turn number atomically (best-effort). @@ -434,7 +477,7 @@ export async function haltPersistentAgentTurn(input: HaltTurnInput) { .returning(); if (turn) { - const agent = await getPersistentAgent(turn.agentId); + const agent = await getPersistentAgentUnscoped(turn.agentId); if (agent) { await publishPersistentAgentEvent({ type: "persistent_agent:turn_halted", @@ -496,7 +539,7 @@ export async function appendPersistentAgentLog(input: AppendLogInput) { }) .returning(); - const agent = await getPersistentAgent(input.agentId); + const agent = await getPersistentAgentUnscoped(input.agentId); if (agent) { await publishPersistentAgentEvent({ type: "persistent_agent:log", diff --git a/apps/api/src/workers/persistent-agent-worker.ts b/apps/api/src/workers/persistent-agent-worker.ts index 494148ff..8ee7998e 100644 --- a/apps/api/src/workers/persistent-agent-worker.ts +++ b/apps/api/src/workers/persistent-agent-worker.ts @@ -211,7 +211,7 @@ export function startPersistentAgentWorker() { const log = logger.child({ agentId, jobId: job.id, persistentAgent: true }); // 1. Verify agent is in QUEUED state and claim by transitioning to PROVISIONING. - const agent = await paService.getPersistentAgent(agentId); + const agent = await paService.getPersistentAgentUnscoped(agentId); if (!agent) { log.warn("Persistent agent not found, skipping job"); return; @@ -238,7 +238,7 @@ export function startPersistentAgentWorker() { } // Re-fetch to get the new updated_at for downstream CAS. - const claimedAgent = await paService.getPersistentAgent(agentId); + const claimedAgent = await paService.getPersistentAgentUnscoped(agentId); if (!claimedAgent) return; let turn: { id: string; turnNumber: number } | null = null; @@ -290,7 +290,7 @@ export function startPersistentAgentWorker() { } // Transition PROVISIONING → RUNNING (CAS). - const provAgent = await paService.getPersistentAgent(agentId); + const provAgent = await paService.getPersistentAgentUnscoped(agentId); if (!provAgent) throw new Error("Agent disappeared during provisioning"); await paService.transitionPersistentAgentState( agentId, @@ -461,7 +461,7 @@ export function startPersistentAgentWorker() { if (success) { // RUNNING → IDLE on success, reset failure counter. - const after = await paService.getPersistentAgent(agentId); + const after = await paService.getPersistentAgentUnscoped(agentId); if (after) { await paService.transitionPersistentAgentState( agentId, @@ -478,7 +478,7 @@ export function startPersistentAgentWorker() { } } else { // Failure path — escalate or recover. - const after = await paService.getPersistentAgent(agentId); + const after = await paService.getPersistentAgentUnscoped(agentId); if (after) { const nextFailures = after.consecutiveFailures + 1; const escalate = nextFailures >= after.consecutiveFailureLimit; @@ -509,7 +509,7 @@ export function startPersistentAgentWorker() { .catch(() => {}); } // Try to recover the agent state to IDLE so the reconciler can decide what to do. - const after = await paService.getPersistentAgent(agentId); + const after = await paService.getPersistentAgentUnscoped(agentId); if (after && after.state !== PersistentAgentState.IDLE) { const nextFailures = after.consecutiveFailures + 1; const escalate = nextFailures >= after.consecutiveFailureLimit; diff --git a/apps/api/src/workers/workflow-trigger-worker.ts b/apps/api/src/workers/workflow-trigger-worker.ts index 40d9ace6..7f76a8cf 100644 --- a/apps/api/src/workers/workflow-trigger-worker.ts +++ b/apps/api/src/workers/workflow-trigger-worker.ts @@ -150,9 +150,9 @@ async function dispatchTrigger(trigger: { } if (trigger.targetType === "persistent_agent") { - const { getPersistentAgent, wakeAgent, buildSenderId } = + const { getPersistentAgentUnscoped, wakeAgent, buildSenderId } = await import("../services/persistent-agent-service.js"); - const agent = await getPersistentAgent(trigger.targetId); + const agent = await getPersistentAgentUnscoped(trigger.targetId); if (!agent) { logger.warn( { triggerId: trigger.id, agentId: trigger.targetId }, diff --git a/apps/api/src/ws/persistent-agent-stream.ts b/apps/api/src/ws/persistent-agent-stream.ts index b83507a8..c5da1792 100644 --- a/apps/api/src/ws/persistent-agent-stream.ts +++ b/apps/api/src/ws/persistent-agent-stream.ts @@ -8,7 +8,7 @@ import { z } from "zod"; import { createSubscriber } from "../services/event-bus.js"; import { authenticateWs } from "./ws-auth.js"; import { - getPersistentAgent, + getPersistentAgentUnscoped, listPersistentAgentTurns, listTurnLogs, } from "../services/persistent-agent-service.js"; @@ -33,7 +33,7 @@ export async function persistentAgentStreamWs(app: FastifyInstance) { } const { agentId } = z.object({ agentId: z.string() }).parse(req.params); - const agent = await getPersistentAgent(agentId); + const agent = await getPersistentAgentUnscoped(agentId); if (!agent) { socket.close(4404, "Persistent agent not found"); releaseConnection(clientIp);