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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 11:16:59 -05:00
1 parent 240227dada
commit aa9104df08
2 files changed
+27 -9

No files matched your search

+5 -4
View File
@@ -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");
+22 -5
View File
@@ -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<string, unknown>;
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);