Require authenticated Copilot model verification before agent adoption

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 10:15:48 -05:00
1 parent 5cb6ad5207
commit 9502697022
4 files changed
+92 -6

No files matched your search

@@ -63,6 +63,63 @@ 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 => {
const { unprocessable } = await import("../errors.js");
const { errorHandler } = await import("../middleware/index.js");
const owner = `copilot-model-${action}`, 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 } });
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);
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockRejectedValue(unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" }));
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const config = { ...savedConfig, model: "unavailable-copilot-model" };
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 } });
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");
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(savedConfig);
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
expect(JSON.stringify(response.body)).not.toContain(token);
} finally {
probe.mockRestore();
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
}
});
it("verifies the exact available Copilot model before a model-only edit with an unchanged connection", async () => {
const { errorHandler } = await import("../middleware/index.js");
const owner = "copilot-model-valid", id = randomUUID();
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
await db.insert(agents).values({ id, companyId, name: "Copilot available model", adapterType: "paperclip_runner", adapterConfig: { ...config, model: "previous-model" }, runtimeConfig: { aiConnection } });
const token = "github_pat_fixture_model_valid";
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Valid model", apiKey: token, agentIds: [id], allAgents: false }, token);
await service.setDefault(companyId, owner, account.grantId);
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: config.model, label: "GPT" }] });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
try {
const response = await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, config.model);
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toMatchObject(config);
expect(response.body.runtimeConfig.aiConnection).toEqual(aiConnection);
} finally { probe.mockRestore(); }
});
it("passes the live sandbox worker manager to Copilot connection verification", async () => {
const pluginWorkerManager = {} as PluginWorkerManager;
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({
+12 -3
View File
@@ -3576,14 +3576,17 @@ export function agentRoutes(
// Hiring is allowed before the responsible user has connected this
// provider. Execution still resolves credentials and creates the normal
// task connection request; compatibility and access denials stay errors.
if (newAgent && !test && binding.mode === "responsible_user" && error instanceof HttpError
if (newAgent && !test && binding.provider !== "github" && binding.mode === "responsible_user" && error instanceof HttpError
&& ["ai_connection_default_missing", "ai_connection_missing", "ai_connection_unavailable", "ai_connection_responsible_user_missing"].includes(String(asRecord(error.details)?.code))) {
return null;
}
throw error;
});
if (!selection) return undefined;
if (test) {
// Copilot model availability is account-specific. Verify the selected
// packaged runtime before creation/adoption even when a token is saved.
// This metadata-only path sends no model prompt.
if (test || binding.provider === "github") {
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId);
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId);
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId });
@@ -3591,6 +3594,10 @@ export function agentRoutes(
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => {
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
if (binding.provider === "github" && result.status === "fail") {
const failure = result.checks.find(check => check.level === "error");
throw unprocessable(failure?.message ?? "Copilot metadata verification failed.", { code: failure?.code ?? "COPILOT_REQUEST_FAILED" });
}
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
code: "ai_connection_validation_failed",
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
@@ -5829,7 +5836,9 @@ export function agentRoutes(
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
if (changed || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
}
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {