From b9dadcb9de8dbfb3106c22ec89cca02ffafddf12 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 17:46:41 -0500 Subject: [PATCH] Restore default environment route coverage Co-Authored-By: Paperclip --- .../agent-permissions-routes.test.ts | 246 +++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index cf51095fa2..e5afd65fb5 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -19,6 +19,7 @@ const baseAgent = { adapterType: "process", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: null, budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, @@ -91,13 +92,24 @@ 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 mockEnvironmentService = vi.hoisted(() => ({})); +const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn()); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockInstanceSettingsService = vi.hoisted(() => ({ getGeneral: vi.fn(), })); function registerModuleMocks() { + 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(), @@ -307,6 +319,8 @@ describe.sequential("agent permission routes", () => { mockGetTelemetryClient.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); + mockEnvironmentService.getById.mockReset(); + mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -338,6 +352,7 @@ 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, @@ -360,6 +375,9 @@ 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); }); @@ -834,6 +852,232 @@ describe.sequential("agent permission routes", () => { })); }); + it("rejects creating an agent with an environment from another company", async () => { + const environmentId = "33333333-3333-4333-8333-333333333333"; + mockEnvironmentService.getById.mockResolvedValue({ + id: environmentId, + companyId: "other-company", + driver: "local", + config: {}, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + defaultEnvironmentId: environmentId, + })); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("Environment not found"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + + it("rejects creating an agent with an unsupported default environment driver", async () => { + const environmentId = "33333333-3333-4333-8333-333333333333"; + mockEnvironmentService.getById.mockResolvedValue({ + id: environmentId, + companyId, + driver: "ssh", + config: {}, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + defaultEnvironmentId: environmentId, + })); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + + const sshCapableAdapterCases = [ + { adapterType: "codex_local", name: "Codex Builder", adapterConfig: {} }, + { adapterType: "claude_local", name: "Claude Builder", adapterConfig: {} }, + { adapterType: "gemini_local", name: "Gemini Builder", adapterConfig: {} }, + { adapterType: "opencode_local", name: "OpenCode Builder", adapterConfig: { model: "opencode/gpt-5-nano" } }, + { adapterType: "cursor", name: "Cursor Builder", adapterConfig: {} }, + { adapterType: "pi_local", name: "Pi Builder", adapterConfig: { model: "openai/gpt-5.4-mini" } }, + ]; + + for (const adapterCase of sshCapableAdapterCases) { + it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => { + const environmentId = "33333333-3333-4333-8333-333333333333"; + mockEnvironmentService.getById.mockResolvedValue({ + id: environmentId, + companyId, + driver: "ssh", + config: {}, + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + name: adapterCase.name, + adapterType: adapterCase.adapterType, + defaultEnvironmentId: environmentId, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: adapterCase.name, + role: "engineer", + adapterType: adapterCase.adapterType, + adapterConfig: adapterCase.adapterConfig, + defaultEnvironmentId: environmentId, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + adapterType: adapterCase.adapterType, + defaultEnvironmentId: environmentId, + }), + ); + }); + } + + it("rejects updating an agent with an unsupported default environment driver", async () => { + const environmentId = "33333333-3333-4333-8333-333333333333"; + mockEnvironmentService.getById.mockResolvedValue({ + id: environmentId, + companyId, + driver: "ssh", + config: {}, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: environmentId, + })); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); + expect(mockAgentService.update).not.toHaveBeenCalled(); + }); + + for (const adapterCase of sshCapableAdapterCases) { + it(`allows updating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => { + const environmentId = "33333333-3333-4333-8333-333333333333"; + mockEnvironmentService.getById.mockResolvedValue({ + id: environmentId, + companyId, + driver: "ssh", + config: {}, + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: adapterCase.adapterType, + adapterConfig: adapterCase.adapterConfig, + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: adapterCase.adapterType, + adapterConfig: adapterCase.adapterConfig, + defaultEnvironmentId: environmentId, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: environmentId, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + agentId, + expect.objectContaining({ + defaultEnvironmentId: environmentId, + }), + expect.anything(), + ); + }); + } + + it("rejects switching an agent away from an SSH-capable runtime without clearing its SSH default", async () => { + const environmentId = "33333333-3333-4333-8333-333333333333"; + mockEnvironmentService.getById.mockResolvedValue({ + id: environmentId, + companyId, + driver: "ssh", + config: {}, + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + defaultEnvironmentId: environmentId, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + adapterType: "process", + })); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); + expect(mockAgentService.update).not.toHaveBeenCalled(); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ {