diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 307a9c070e..b9473434a6 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; const repoRoot = process.cwd(); +const serverRoot = path.join(repoRoot, "server"); const serverTestsDir = path.join(repoRoot, "server", "src", "__tests__"); const nonServerProjects = [ "@paperclipai/shared", @@ -63,6 +64,10 @@ function toRepoPath(file) { return path.relative(repoRoot, file).split(path.sep).join("/"); } +function toServerPath(file) { + return path.relative(serverRoot, file).split(path.sep).join("/"); +} + function isRouteOrAuthzTest(file) { if (routeTestPattern.test(file)) { return true; @@ -99,10 +104,13 @@ function runVitest(args, label) { const routeTests = walk(serverTestsDir) .filter((file) => isRouteOrAuthzTest(toRepoPath(file))) - .map((file) => ({ repoPath: toRepoPath(file) })) + .map((file) => ({ + repoPath: toRepoPath(file), + serverPath: toServerPath(file), + })) .sort((a, b) => a.repoPath.localeCompare(b.repoPath)); -const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.repoPath]); +const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); for (const project of nonServerProjects) { runVitest(["--project", project], `non-server project ${project}`); } diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 2e2014ee1b..cf51095fa2 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,7 +1,6 @@ -import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -20,7 +19,6 @@ const baseAgent = { adapterType: "process", adapterConfig: {}, runtimeConfig: {}, - defaultEnvironmentId: null, budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, @@ -61,10 +59,6 @@ const mockBudgetService = vi.hoisted(() => ({ upsertPolicy: vi.fn(), })); -const mockEnvironmentService = vi.hoisted(() => ({ - getById: vi.fn(), -})); - const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), @@ -97,25 +91,13 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); -const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn()); +const mockEnvironmentService = vi.hoisted(() => ({})); const mockInstanceSettingsService = vi.hoisted(() => ({ getGeneral: vi.fn(), })); function registerModuleMocks() { - vi.doMock("../routes/agents.js", async () => vi.importActual("../routes/agents.js")); - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); - vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js")); - vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); - vi.doMock("@paperclipai/adapter-opencode-local/server", async () => { - const actual = await vi.importActual("@paperclipai/adapter-opencode-local/server"); - return { - ...actual, - ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable, - }; - }); - vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, trackErrorHandlerCrash: vi.fn(), @@ -219,44 +201,53 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = }; } -let sharedServer: Server | null = null; - -async function closeSharedServer() { - if (!sharedServer) return; - const server = sharedServer; - sharedServer = null; - await new Promise((resolve, reject) => { - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - async function createApp(actor: Record, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) { - await closeSharedServer(); const [{ errorHandler }, { agentRoutes }] = await Promise.all([ - import("../middleware/index.js"), - import("../routes/agents.js"), + import("../middleware/index.js") as Promise, + import("../routes/agents.js") as Promise, ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + ...actor, + companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds, + }; next(); }); app.use("/api", agentRoutes(createDbStub(dbOptions) as any)); app.use(errorHandler); - sharedServer = app.listen(0, "127.0.0.1"); - await new Promise((resolve) => { - sharedServer?.once("listening", resolve); - }); - return sharedServer; + return app; +} + +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } } describe.sequential("agent permission routes", () => { - afterEach(closeSharedServer); - beforeEach(() => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); @@ -316,7 +307,6 @@ describe.sequential("agent permission routes", () => { mockGetTelemetryClient.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); - mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -348,7 +338,6 @@ describe.sequential("agent permission routes", () => { mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); - mockEnvironmentService.getById.mockResolvedValue(null); mockAgentInstructionsService.materializeManagedBundle.mockImplementation( async (agent: Record, files: Record) => ({ bundle: null, @@ -371,9 +360,6 @@ describe.sequential("agent permission routes", () => { mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, }); - mockEnsureOpenCodeModelConfiguredAndAvailable.mockResolvedValue([ - { id: "opencode/gpt-5-nano", label: "opencode/gpt-5-nano" }, - ]); mockLogActivity.mockResolvedValue(undefined); }); @@ -388,7 +374,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).get(`/api/agents/${agentId}`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); expect(res.status).toBe(200); expect(res.body.adapterConfig).toEqual({}); @@ -406,7 +392,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).get(`/api/companies/${companyId}/agents`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/${companyId}/agents`)); expect(res.status).toBe(200); expect(res.body).toEqual([ @@ -429,9 +415,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}`) - .send({ title: "Compromised" }); + .send({ title: "Compromised" })); expect(res.status).toBe(403); }); @@ -447,9 +433,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/keys`) - .send({ name: "backdoor" }); + .send({ name: "backdoor" })); expect(res.status).toBe(403); }); @@ -465,9 +451,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/wakeup`) - .send({}); + .send({})); expect(res.status).toBe(403); }); @@ -481,7 +467,7 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}`) .send({ adapterConfig: { @@ -490,7 +476,7 @@ describe.sequential("agent permission routes", () => { provisionCommand: "touch /tmp/paperclip-rce", }, }, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("host-executed workspace commands"); @@ -506,14 +492,14 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}`) .send({ adapterConfig: { instructionsRootPath: "/etc", instructionsEntryFile: "passwd", }, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); @@ -529,9 +515,9 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}/instructions-path`) - .send({ path: "/etc/passwd" }); + .send({ path: "/etc/passwd" })); expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); @@ -549,7 +535,7 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agent-hires`) .send({ name: "Injected", @@ -559,7 +545,7 @@ describe.sequential("agent permission routes", () => { instructionsRootPath: "/etc", instructionsEntryFile: "passwd", }, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); @@ -578,14 +564,14 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Backdoor", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("agents:create"); @@ -604,16 +590,16 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); - expect([200, 201]).toContain(res.status); + expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockAgentService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ @@ -642,14 +628,14 @@ describe.sequential("agent permission routes", () => { { requireBoardApprovalForNewAgents: true }, ); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); expect(res.status).toBe(409); expect(res.body.error).toContain("/agent-hires"); @@ -667,14 +653,14 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); expect([200, 201]).toContain(res.status); expect(mockAccessService.ensureMembership).toHaveBeenCalledWith( @@ -703,9 +689,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .get(`/api/companies/${companyId}/agents`) - .query({ urlKey: "builder" }); + .query({ urlKey: "builder" })); expect(res.status).toBe(400); expect(res.body.error).toContain("urlKey"); @@ -721,7 +707,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", @@ -733,7 +719,7 @@ describe.sequential("agent permission routes", () => { intervalSec: 3600, }, }, - }); + })); expect([200, 201]).toContain(res.status); expect(mockAgentService.create).toHaveBeenCalledWith( @@ -759,7 +745,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agent-hires`) .send({ name: "Builder", @@ -771,7 +757,7 @@ describe.sequential("agent permission routes", () => { intervalSec: 3600, }, }, - }); + })); expect(res.status).toBe(201); expect(mockAgentService.create).toHaveBeenCalledWith( @@ -811,9 +797,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/approve`) - .send({}); + .send({})); expect(res.status).toBe(200); expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId); @@ -837,9 +823,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/approve`) - .send({}); + .send({})); expect(res.status).toBe(409); expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); @@ -848,524 +834,6 @@ describe.sequential("agent permission routes", () => { })); }); - it("rejects creating an agent with an environment from another company", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId: "other-company", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Builder", - role: "engineer", - adapterType: "process", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain("Environment not found"); - }); - - it("rejects creating an agent with an unsupported non-local default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Builder", - role: "engineer", - adapterType: "process", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); - }); - - it("allows creating a codex agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Codex Builder", - role: "engineer", - adapterType: "codex_local", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect([200, 201]).toContain(res.status); - }); - - it("allows creating a claude agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "claude_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Claude Builder", - role: "engineer", - adapterType: "claude_local", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating a gemini agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "gemini_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Gemini Builder", - role: "engineer", - adapterType: "gemini_local", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating an opencode agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "opencode_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "OpenCode Builder", - role: "engineer", - adapterType: "opencode_local", - adapterConfig: { - model: "opencode/gpt-5-nano", - }, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating a cursor agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "cursor", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Cursor Builder", - role: "engineer", - adapterType: "cursor", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating a pi agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "pi_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Pi Builder", - role: "engineer", - adapterType: "pi_local", - adapterConfig: { - model: "openai/gpt-5.4-mini", - }, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect([200, 201]).toContain(res.status); - }); - - it("rejects updating an agent with an unsupported non-local default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); - }); - - it("allows updating a codex agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a claude agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "claude_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "claude_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a gemini agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "gemini_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "gemini_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating an opencode agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "opencode_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "opencode_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a cursor agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "cursor", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "cursor", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a pi agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "pi_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "pi_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("rejects switching a codex agent away from SSH-capable runtime without clearing its SSH default", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - adapterType: "process", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); - }); - it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { @@ -1389,7 +857,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).get(`/api/agents/${agentId}`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); expect(res.status).toBe(200); expect(res.body.access.canAssignTasks).toBe(true); @@ -1410,9 +878,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}/permissions`) - .send({ canCreateAgents: true, canAssignTasks: false }); + .send({ canCreateAgents: true, canAssignTasks: false })); expect(res.status).toBe(200); expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( @@ -1445,9 +913,9 @@ describe.sequential("agent permission routes", () => { source: "agent_key", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .get("/api/agents/me/inbox/mine") - .query({ userId: "board-user" }); + .query({ userId: "board-user" })); expect(res.status).toBe(200); expect(res.body).toEqual([ @@ -1482,7 +950,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + const res = await requestApp(app, (baseUrl) => request(baseUrl).post("/api/heartbeat-runs/run-1/cancel").send({})); expect(res.status).toBe(403); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 5247ec7412..ddafeb9fc7 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -32,72 +34,86 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ getById: vi.fn(), })); -function registerModuleMocks() { - vi.doMock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => ({ - getById: vi.fn(), - }), - documentService: () => mockDocumentsService, - environmentService: () => ({}), - executionWorkspaceService: () => mockExecutionWorkspaceService, - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => mockGoalService, - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => mockProjectService, - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({ - listForIssue: vi.fn(async () => []), - }), - })); +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); - vi.doMock("../services/execution-workspaces.js", () => ({ - executionWorkspaceService: () => mockExecutionWorkspaceService, - })); -} +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); -async function createApp() { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/issues.js"), - vi.importActual("../middleware/index.js"), - ]); +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), +})); + +const mockIssueReferenceService = vi.hoisted(() => ({ + deleteDocumentSource: vi.fn(async () => undefined), + diffIssueReferenceSummary: vi.fn(() => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + })), + emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })), + listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })), + syncComment: vi.fn(async () => undefined), + syncDocument: vi.fn(async () => undefined), + syncIssue: vi.fn(async () => undefined), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); + +const mockWorkProductService = vi.hoisted(() => ({ + listForIssue: vi.fn(async () => []), +})); + +const mockEnvironmentService = vi.hoisted(() => ({})); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => mockDocumentsService, + environmentService: () => mockEnvironmentService, + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => mockFeedbackService, + goalService: () => mockGoalService, + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => mockIssueReferenceService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + routineService: () => mockRoutineService, + workProductService: () => mockWorkProductService, +})); + +vi.mock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, +})); + +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -147,16 +163,9 @@ const projectGoal = { updatedAt: new Date("2026-03-20T00:00:00Z"), }; -describe("issue goal context routes", () => { +describe.sequential("issue goal context routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../services/execution-workspaces.js"); - vi.doUnmock("../routes/issues.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue); mockIssueService.getAncestors.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); @@ -213,7 +222,7 @@ describe("issue goal context routes", () => { }); it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => { - const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); + const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); expect(res.status).toBe(200); expect(res.body.goalId).toBe(projectGoal.id); @@ -231,7 +240,7 @@ describe("issue goal context routes", () => { }); it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => { - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -257,7 +266,7 @@ describe("issue goal context routes", () => { updatedAt: new Date("2026-04-19T12:00:00.000Z"), }); - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -288,7 +297,7 @@ describe("issue goal context routes", () => { blocks: [], }); - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -323,7 +332,7 @@ describe("issue goal context routes", () => { ], }); - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index 0791d10dcc..34aaf872f7 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -56,9 +56,37 @@ vi.mock("../routes/workspace-runtime-service-authz.js", () => ({ assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, })); +function registerWorkspaceRouteMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/index.js", () => ({ + environmentService: () => mockEnvironmentService, + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/workspace-runtime.js", () => ({ + cleanupExecutionWorkspaceArtifacts: vi.fn(), + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForExecutionWorkspace: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); + + vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({ + assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices, + assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, + })); +} + let appImportCounter = 0; async function createProjectApp(actor: Record) { + registerWorkspaceRouteMocks(); appImportCounter += 1; const routeModulePath = `../routes/projects.js?workspace-runtime-routes-authz-${appImportCounter}`; const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`; @@ -78,6 +106,7 @@ async function createProjectApp(actor: Record) { } async function createExecutionWorkspaceApp(actor: Record) { + registerWorkspaceRouteMocks(); appImportCounter += 1; const routeModulePath = `../routes/execution-workspaces.js?workspace-runtime-routes-authz-${appImportCounter}`; const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`; diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 4a3639bc14..4047b72340 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ environment: "node", isolate: true, maxConcurrency: 1, + maxWorkers: 1, + minWorkers: 1, pool: "forks", poolOptions: { forks: {