From 3756be3fe6bbc0c318984f0905d472cee174e9da Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 11:54:31 -0500 Subject: [PATCH] Authorize shared Copilot discovery before credential use Apply existing new-agent shared-installation checks and existing-agent configuration authority before resolving a credential or probing any environment. Co-Authored-By: Paperclip --- server/src/__tests__/ai-connections.test.ts | 31 +++++++++++++++++++++ server/src/routes/agents.ts | 11 ++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index df364191bbf..bcbe1e1d1a5 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -164,6 +164,37 @@ describe("managed AI connections", () => { } finally { probe.mockRestore(); } }); + it("keeps Copilot model discovery behind shared installation and agent configuration authority", async () => { + const { errorHandler } = await import("../middleware/index.js"); + const owner = "copilot-discovery-owner", audience = "copilot-discovery-audience"; + await db.insert(companyMemberships).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" }))); + await db.insert(principalPermissionGrants).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", permissionKey: "agents:create" }))); + const token = "github_pat_fixture_shared_discovery"; + const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "shared", name: "Shared discovery", apiKey: token, agentIds: [], allAgents: false }, token); + await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user", subjectId }))); + const binding = { provider: "github", method: "api_key", mode: "shared", ...account }; + const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] }); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? audience), companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); }); + app.use("/api", agentRoutes(db)); app.use(errorHandler); + const url = `/api/companies/${companyId}/adapters/paperclip_runner/models`; + const query = { provider: "acpx", acpxAgent: "copilot", aiConnection: JSON.stringify(binding) }; + try { + const denied = await request(app).get(url).query(query); + expect(denied.status, JSON.stringify(denied.body)).toBe(403); + expect(denied.body.error).toContain("not permitted for this agent"); + expect(probe).not.toHaveBeenCalled(); + const allowed = await request(app).get(url).set("x-test-user", owner).query(query); + expect(allowed.status, JSON.stringify(allowed.body)).toBe(200); + expect(probe).toHaveBeenCalledExactlyOnceWith(db, companyId, token, null, undefined, { pluginWorkerManager: undefined }); + probe.mockClear(); + const foreign = await request(app).get(url).set("x-test-user", owner).query({ ...query, agentId: randomUUID() }); + expect(foreign.status).toBe(404); + expect(probe).not.toHaveBeenCalled(); + expect(JSON.stringify([denied.body, allowed.body, foreign.body])).not.toContain(token); + } finally { probe.mockRestore(); } + }); + it("preserves repository GitHub credentials for managed non-Copilot execution", async () => { const owner = "repo-github-owner"; await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6a8fcd0de83..7c7037d9388 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3404,9 +3404,16 @@ export function agentRoutes( catch { throw unprocessable("Invalid AI connection binding"); } } if (binding.provider !== "github") throw unprocessable("Select a GitHub Copilot AI connection"); + const modelAgentId = asNonEmptyString(req.query.agentId); + if (modelAgentId) { + const modelAgent = await svc.getById(modelAgentId); + if (!modelAgent || modelAgent.companyId !== companyId) throw notFound("Agent not found"); + await assertCanUpdateAgent(req, modelAgent); + } else await assertCanCreateAgentsForCompany(req, companyId); + const allowUninstalledShared = !modelAgentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding); const service = aiConnectionService(db); - const selection = await service.select({ companyId, userId, agentId: asNonEmptyString(req.query.agentId) ?? "00000000-0000-0000-0000-000000000000", - adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared: req.actor.type === "board" }); + const selection = await service.select({ companyId, userId, agentId: modelAgentId ?? "00000000-0000-0000-0000-000000000000", + adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared }); const metadata = await probeCopilotConnection(db, companyId, await service.credential(selection), environmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager }); res.setHeader("Cache-Control", "no-store"); res.json(metadata.models);