From aa9104df087406be2a35389a892df8faf3ea7799 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 11:16:59 -0500 Subject: [PATCH] Require saved-account admission for implicit Copilot bindings Copilot create, hire and configuration edits that omit the AI binding now use the responsible user saved account and the existing metadata-only admission checks. Preserve explicit choices, manager inheritance and other provider behavior. Regressions cover unavailable models with explicit and omitted bindings. Co-Authored-By: Paperclip --- server/src/__tests__/ai-connections.test.ts | 9 ++++--- server/src/routes/agents.ts | 27 +++++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index d8e112be74b..df364191bbf 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -63,14 +63,15 @@ beforeAll(async () => { afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); }); describe("managed AI connections", () => { - it.each(["create", "hire", "edit"])("refuses unavailable Copilot models before %s persists configuration", async action => { + it.each(["create", "hire", "edit"].flatMap(action => [true, false].map(connectionProvided => ({ action, connectionProvided }))))( + "refuses unavailable Copilot models before $action persists configuration (connection binding: $connectionProvided)", async ({ action, connectionProvided }) => { const { unprocessable } = await import("../errors.js"); const { errorHandler } = await import("../middleware/index.js"); - const owner = `copilot-model-${action}`, id = randomUUID(); + const owner = `copilot-model-${action}-${connectionProvided}`, id = randomUUID(); const savedConfig = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }; const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const; await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); - await db.insert(agents).values({ id, companyId, name: `Copilot ${action} fixture`, adapterType: "paperclip_runner", adapterConfig: savedConfig, runtimeConfig: { aiConnection } }); + await db.insert(agents).values({ id, companyId, name: `Copilot ${action} fixture`, adapterType: "paperclip_runner", adapterConfig: savedConfig, runtimeConfig: connectionProvided ? { aiConnection } : {} }); const token = `github_pat_fixture_model_${action}`; const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: `Model ${action}`, apiKey: token, agentIds: [id], allAgents: false }, token); await service.setDefault(companyId, owner, account.grantId); @@ -85,7 +86,7 @@ describe("managed AI connections", () => { try { const response = action === "edit" ? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config }) - : await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: `Unavailable ${action}`, role: "general", adapterType: "paperclip_runner", adapterConfig: config, runtimeConfig: { aiConnection } }); + : await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: `Unavailable ${action}`, role: "general", adapterType: "paperclip_runner", adapterConfig: config, ...(connectionProvided ? { runtimeConfig: { aiConnection } } : {}) }); expect(response.status, JSON.stringify(response.body)).toBe(422); expect(response.body).toMatchObject({ error: "The selected Copilot model is unavailable for this account." }); expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, "unavailable-copilot-model"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5470edddb9c..6a8fcd0de83 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2517,11 +2517,20 @@ export function agentRoutes( runtimeConfig: unknown, ) { const normalized = normalizeNewAgentRuntimeConfig(runtimeConfig); - if (req.actor.type !== "agent" || normalized.aiConnection) return normalized; - const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; - if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable"); - const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection); - if (binding) normalized.aiConnection = binding; + if (normalized.aiConnection) return normalized; + if (req.actor.type === "agent") { + const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; + if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable"); + const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection); + if (binding) normalized.aiConnection = binding; + } + // Copilot has no ambient-auth setup path. Omitting a binding still selects + // the responsible user's saved account and must pass the same metadata + // admission as an explicit connection before configuration is persisted. + if (!normalized.aiConnection && adapterType === "paperclip_runner" + && adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "copilot") { + normalized.aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" }; + } return normalized; } @@ -5830,6 +5839,14 @@ export function agentRoutes( }); } if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection; + const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record; + if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection + && requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx" + && nextAiConfig.acpxAgent === "copilot" + && (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) { + requestedRuntimeConfig = { ...(requestedRuntimeConfig ?? existing.runtimeConfig), + aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }; + } const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data; if (nextAiBinding) { await assertCanUpdateAgent(req, existing);