diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index dd49412c784..25dab5e9e2e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1374,6 +1374,13 @@ Invites tab does not hide this Cloud action. ## 10.3 Agents +Saving an existing agent's configuration with a healthy saved AI account checks +current account access, installation, provider/model compatibility, and environment +selection without allocating a computer or running a provider hello test. Use +**Test** to verify the runtime. Unverified legacy-account adoption, task connection +adoption, and Copilot's account-specific model checks retain their live validation. +A configuration save confirms persistence; it does not certify runtime readiness. + The Harness / Runtime configuration page offers **Upgrade runner** for existing legacy Claude, Codex, and Grok Build agents when the target environment selects Paperclip Runner by default. The action uses the normal authorized agent PATCH with diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index c9d591c1b71..8b8c24511bb 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -219,6 +219,16 @@ environment, and consent checks as other edits. It adds no agent permission or credential fallback. Pool and externally managed agents use their existing configuration surfaces. Board browser verification does not qualify agent access. +Ordinary configuration PATCH requests with healthy installed AI accounts validate +current access, compatibility and environment selection without allocating a +computer or sending a provider prompt. Legacy-account adoption, task connection +adoption and Copilot model verification retain their live checks. Explicit Test +and execution keep runtime validation. This changes save latency and validation +timing; it grants no account, environment or execution authority. The isolated +PostgreSQL route fixtures in +[agent-configuration-save.test.ts](../../server/src/__tests__/agent-configuration-save.test.ts) +cover board saves and revoked-account rejection, not native-agent access. + The existing-agent upgrade notice for Claude, Codex, and Grok Build uses this configuration PATCH. It does not grant configuration, account, or environment access. The server resolves the saved settings under normal company/configuration authorization. Grok migration preserves diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 1fac593afb4..14291c099e0 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -83,6 +83,12 @@ resource allocation waits for approval. ### `agent-configuration` +On Harness / Runtime, select an installed healthy AI account or change its model +and save. The save checks current access and compatibility without starting a +computer or provider hello test. **Test** still verifies the runtime. Unverified +legacy accounts, task connection adoption and Copilot model checks retain live +validation. Verify saved configuration separately from runtime readiness. + Open `/agents/:agentId` and the configuration/instruction surfaces it exposes. The agent nav has two sections: **Agent** (Overview, Identity at `/agents/:agentId/identity`, Harness / Runtime, Secrets & variables, diff --git a/server/src/__tests__/agent-configuration-save.test.ts b/server/src/__tests__/agent-configuration-save.test.ts new file mode 100644 index 00000000000..02f36a6ac80 --- /dev/null +++ b/server/src/__tests__/agent-configuration-save.test.ts @@ -0,0 +1,188 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { agents, agentConfigRevisions, activityLog, builtInManagedResources, companies, companyMemberships, connectionGrants, createDb, environments, toolConnections, toolConnectionInstalls } from "@paperclipai/db"; +import { agentRoutes } from "../routes/agents.js"; +import { aiConnectionService } from "../services/ai-connections.js"; +import * as environmentTests from "../services/agent-environment-test.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +const realEnvironmentTests = environmentTests.agentEnvironmentTestService; + +describePostgres("agent configuration save latency", () => { + let database: Awaited>; + let db: ReturnType; + let home: string; + let app: express.Express; + let agent: typeof agents.$inferSelect; + let account: { connectionId: string; grantId: string }; + let fixtureNumber = 0; + const userId = randomUUID(); + const binding = { provider: "openai", method: "api_key", mode: "responsible_user" } as const; + const allocate = vi.fn["resolveAdapterTestExecutionContext"]>(); + const probe = vi.fn["testManagedEnvironment"]>(); + const release = vi.fn(async () => undefined); + const readAgent = async () => (await db.select().from(agents).where(eq(agents.id, agent.id)))[0]; + + beforeAll(async () => { + home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-config-save-"))); + vi.stubEnv("PAPERCLIP_HOME", home); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "configuration-save-fixture"); + database = await startEmbeddedPostgresTestDatabase("paperclip-config-save-db-"); + db = createDb(database.connectionString); + }, 90_000); + + beforeEach(async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Configuration save", issuePrefix: `CS${++fixtureNumber}` }); + await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" }); + [agent] = await db.insert(agents).values({ companyId, name: "Configuration agent", adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }).returning(); + account = await aiConnectionService(db).save(companyId, userId, { + provider: "openai", method: "api_key", ownership: "personal", name: "Saved account", apiKey: "fixture-save-key", agentIds: [agent.id], allAgents: false, + }, "fixture-save-key"); + await aiConnectionService(db).setDefault(companyId, userId, account.grantId); + allocate.mockReset().mockRejectedValue(new Error("A save must not allocate a remote computer")); + probe.mockReset().mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), + checks: [{ code: "codex_hello_probe_passed", level: "info", message: "Fixture probe passed" }] }); + release.mockClear(); + vi.spyOn(environmentTests, "agentEnvironmentTestService").mockImplementation((database, manager) => ({ + ...realEnvironmentTests(database, manager), resolveAdapterTestExecutionContext: allocate, testManagedEnvironment: probe, + })); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("A save must not contact the provider")); + app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId, companyIds: [companyId] }; next(); }); + app.use("/api", agentRoutes(db)); + app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error.status ?? 500).json({ error: error.message }); + }); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(async () => { + await database?.cleanup(); vi.unstubAllEnvs(); + if (home) await rm(home, { recursive: true, force: true }); + }); + + it("saves an installed healthy account without allocating a computer or sending a prompt", async () => { + const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect((await readAgent()).runtimeConfig.aiConnection).toEqual(binding); + expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(JSON.stringify(response.body)).not.toContain("fixture-save-key"); + expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(1); + expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.updated")))).toHaveLength(1); + }); + + it("saves model edits against an existing healthy account without a runtime probe", async () => { + await db.update(agents).set({ runtimeConfig: { aiConnection: binding } }).where(eq(agents.id, agent.id)); + const response = await request(app).patch(`/api/agents/${agent.id}`).send({ adapterConfig: { model: "gpt-5.6-luna" } }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect((await readAgent()).adapterConfig.model).toBe("gpt-5.6-luna"); + expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("saves a roster connection edit without a runtime probe and rejects a stale retry", async () => { + const patch = { runtimeSettings: { harness: "codex_local", aiConnection: binding, + expected: { model: "gpt-5.6-sol", aiConnection: null } } }; + const response = await request(app).patch(`/api/agents/${agent.id}`).send(patch); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect((await readAgent()).runtimeConfig.aiConnection).toEqual(binding); + expect((await request(app).patch(`/api/agents/${agent.id}`).send(patch)).status).toBe(409); + expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); + }); + + it("rejects an account without an installation for this agent", async () => { + await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId)); + const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } }); + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect((await readAgent()).runtimeConfig.aiConnection).toBeUndefined(); + expect(allocate).not.toHaveBeenCalled(); + }); + + it("rejects a revoked account before saving or allocating a computer", async () => { + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId)); + const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } }); + expect(response.status, JSON.stringify(response.body)).toBe(422); + expect((await readAgent()).runtimeConfig.aiConnection).toBeUndefined(); + expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(0); + expect(allocate).not.toHaveBeenCalled(); + }); + + it.each(["foreign", "same-company", "global"])("checks %s environment ownership without a runtime probe", async ownership => { + const [environment] = await db.insert(environments).values({ name: `Save target ${agent.companyId}`, driver: "sandbox", config: { provider: "daytona" } }).returning(); + if (ownership !== "global") { + const companyId = ownership === "foreign" ? randomUUID() : agent.companyId; + if (ownership === "foreign") await db.insert(companies).values({ id: companyId, name: "Foreign company", issuePrefix: `FC${++fixtureNumber}` }); + await db.insert(builtInManagedResources).values({ companyId, bundleKey: "managed-environment", resourceKind: "environment", + resourceKey: "managed-sandbox", resourceId: environment.id, stockVersion: "1", stockHash: "fixture" }); + } + const response = await request(app).patch(`/api/agents/${agent.id}`).send({ defaultEnvironmentId: environment.id, runtimeConfig: { aiConnection: binding } }); + expect(response.status, JSON.stringify(response.body)).toBe(ownership === "foreign" ? 403 : 200); + const saved = await readAgent(); + expect(saved.defaultEnvironmentId).toBe(ownership === "foreign" ? null : environment.id); + expect(saved.runtimeConfig.aiConnection).toEqual(ownership === "foreign" ? undefined : binding); + expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(ownership === "foreign" ? 0 : 1); + expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); + }); + + it.each(["inherited", "existing-binding"])("rejects a foreign environment with %s settings", async mode => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Foreign company", issuePrefix: `FC${++fixtureNumber}` }); + const [environment] = await db.insert(environments).values({ name: `Foreign target ${companyId}`, driver: "sandbox", config: { provider: "daytona" } }).returning(); + await db.insert(builtInManagedResources).values({ companyId, bundleKey: "managed-environment", resourceKind: "environment", + resourceKey: "managed-sandbox", resourceId: environment.id, stockVersion: "1", stockHash: "fixture" }); + const settings = instanceSettingsService(db); + const previousDefault = (await settings.get()).defaultEnvironmentId; + try { + if (mode === "inherited") await settings.update({ defaultEnvironmentId: environment.id }); + else await db.update(agents).set({ runtimeConfig: { aiConnection: binding } }).where(eq(agents.id, agent.id)); + const response = await request(app).patch(`/api/agents/${agent.id}`).send(mode === "inherited" + ? { runtimeConfig: { aiConnection: binding } } : { defaultEnvironmentId: environment.id }); + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect((await readAgent()).defaultEnvironmentId).toBeNull(); + expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(0); + expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); + } finally { if (mode === "inherited") await settings.update({ defaultEnvironmentId: previousDefault }); } + }); + + it("keeps live validation for an unverified legacy account", async () => { + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.connectionId)); + await db.update(toolConnections).set({ healthStatus: "unknown", config: { ...connection.config, aiLegacyAdoption: true } }).where(eq(toolConnections.id, account.connectionId)); + allocate.mockResolvedValue({ executionTarget: null, environmentName: "Fixture computer", fallbackChecks: [], release }); + probe.mockResolvedValueOnce({ adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), + checks: [{ code: "codex_hello_probe_auth_required", level: "error", message: "Fixture login rejected" }] }); + const failed = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } }); + expect(failed.status, JSON.stringify(failed.body)).toBe(422); + expect((await readAgent()).runtimeConfig.aiConnection).toBeUndefined(); + const saved = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } }); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect(probe).toHaveBeenCalledTimes(2); expect(release).toHaveBeenCalledTimes(2); + const [verified] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.connectionId)); + expect(verified.healthStatus).toBe("ok"); expect(verified.config.aiLegacyAdoption).toBe(false); + }); + + it("profiles a save with controlled remote allocation latency", async () => { + const delayMs = Number(process.env.PAPERCLIP_CONFIG_SAVE_PROFILE_DELAY_MS ?? 50); + allocate.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, delayMs)); + return { executionTarget: null, environmentName: "Delayed fixture computer", fallbackChecks: [], release }; + }); + const startedAt = performance.now(); + const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } }); + const elapsedMs = performance.now() - startedAt; + if (process.env.PAPERCLIP_CONFIG_SAVE_PROFILE_DELAY_MS) { + process.stdout.write(`${JSON.stringify({ profile: "agent-configuration-save", remoteAllocationDelayMs: delayMs, elapsedMs, + allocations: allocate.mock.calls.length, probes: probe.mock.calls.length, status: response.status })}\n`); + } + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); + }, 60_000); +}); diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index 024f133c9a0..bbfaa12ae5b 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -9,7 +9,7 @@ import express from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, environments, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db"; +import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, environments, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnections, toolConnectionInstalls } from "@paperclipai/db"; import { type AiConnectionBinding, type AiConnectionPoolMember, type PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { agentRoutes } from "../routes/agents.js"; @@ -94,6 +94,11 @@ function hired(response: request.Response) { return response.body.agent ?? response.body; } +async function markLegacyAdoption(connectionId: string) { + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)); + await db.update(toolConnections).set({ config: { ...connection.config, aiLegacyAdoption: true } }).where(eq(toolConnections.id, connectionId)); +} + async function poolFixture(f: Awaited>, extraMembers: AiConnectionPoolMember[] = []) { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); const pluginKey = `fixture.pool-${f.companyId}`; @@ -257,8 +262,10 @@ describe("agent-created hires use managed AI connections", () => { } finally { unregisterServerAdapter(adapterType); } }); - it.each(["model", "engine", "environment", "harness", "connection"] as const)("probes a changed %s selection and preserves the saved configuration on failure", async (selection) => { + it.each((["model", "engine", "environment", "harness", "connection"] as const).flatMap(selection => + [false, true].map(legacy => ({ selection, legacy }))))("checks a changed $selection selection (legacy: $legacy)", async ({ selection, legacy }) => { const f = await fixture("openai"); + if (legacy) await markLegacyAdoption(f.account.connectionId); await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")); const adapterType = selection === "harness" ? "paperclip_runner" : f.adapterType; @@ -273,11 +280,15 @@ describe("agent-created hires use managed AI connections", () => { : { adapterConfig: { [selection]: selection === "model" ? "changed-model" : "cli" } }; try { const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send(patch); - expect(response.status, JSON.stringify(response.body)).toBe(422); - expect(probe).toHaveBeenCalledOnce(); + expect(response.status, JSON.stringify(response.body)).toBe(legacy ? 422 : 200); + expect(probe).toHaveBeenCalledTimes(legacy ? 1 : 0); const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); - expect(saved).toMatchObject({ adapterType: f.adapterType, adapterConfig: {}, defaultEnvironmentId: null, - runtimeConfig: { aiConnection: f.binding } }); + if (legacy) { + expect(saved).toMatchObject({ adapterType: f.adapterType, adapterConfig: {}, defaultEnvironmentId: null, + runtimeConfig: { aiConnection: f.binding } }); + } else { + expect(saved).toMatchObject(patch); + } } finally { unregisterServerAdapter(adapterType); } }); @@ -315,8 +326,9 @@ describe("agent-created hires use managed AI connections", () => { }); for (const operation of ["test", "save"] as const) { - it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (status: %s)`, async (status) => { + it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (legacy save; status: %s)`, async (status) => { const f = await fixture("anthropic", "api_key"); + if (operation === "save") await markLegacyAdoption(f.account.connectionId); await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); const original = getServerAdapter(f.adapterType); registerServerAdapter({ ...original, testEnvironment: async () => ({ adapterType: f.adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }) }); @@ -334,8 +346,9 @@ describe("agent-created hires use managed AI connections", () => { }); } - it.each(["test", "save"] as const)("%s marks a hello-test authentication rejection as needing attention and reconnect repairs the same default", async (operation) => { + it.each(["test", "save"] as const)("%s marks a hello-test authentication rejection as needing attention and reconnect repairs the same default (legacy save)", async (operation) => { const f = await fixture("anthropic", "subscription"); + if (operation === "save") await markLegacyAdoption(f.account.connectionId); await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); const original = getServerAdapter(f.adapterType); registerServerAdapter({ ...original, testEnvironment: async () => ({ diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 96324216d13..ada29e9cc21 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -2595,7 +2595,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" expect((await service.select(selectionInput)).grant.id).toBe(account.grantId); }); - it.each([false, true])("validates runner account adoption in the inherited sandbox (inline: %s)", async (inline) => { + it.each([false, true])("validates legacy or task account adoption in the inherited sandbox (inline: %s)", async (inline) => { const { agentRoutes } = await import("../routes/agents.js"); const { instanceSettingsService } = await import("../services/instance-settings.js"); const targetModule = await import("../services/environment-execution-target.js"); @@ -2609,6 +2609,11 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }); const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: inline ? [] : [id], allAgents: false }, "fixture-adoption-key"); await service.setDefault(companyId, "alice", account.grantId); + if (!inline) { + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.connectionId)); + await db.update(toolConnections).set({ healthStatus: "unknown", config: { ...connection.config, aiLegacyAdoption: true } }) + .where(eq(toolConnections.id, account.connectionId)); + } const tryDeliver = vi.fn(async (interactionId: string) => { // Dispatch observes committed adoption, never a partially saved choice. expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("accepted"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ad455fc6dd8..5b3339fd54d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3176,7 +3176,7 @@ export function agentRoutes( res.json(detected); }); - async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false, validateModelCatalog = false) { + async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, probe: "none" | "legacy" | "always", newAgent = false, validateModelCatalog = false) { if (binding.mode === "router") { const settings = await import("../services/instance-settings.js"); if (!(await settings.instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Ask your instance operator to enable AI connection routing."); @@ -3205,11 +3205,11 @@ export function agentRoutes( } const userId = responsibleUserForAiRequest(req); const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding); - const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, model: config.model, runnerProvider: config.provider, acpxAgent: config.acpxAgent, binding, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: test }).catch((error: unknown) => { + const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, model: config.model, runnerProvider: config.provider, acpxAgent: config.acpxAgent, binding, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: probe !== "none" }).catch((error: unknown) => { // 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.provider !== "github" && binding.mode === "responsible_user" && error instanceof HttpError + if (newAgent && probe === "none" && 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; } @@ -3221,14 +3221,17 @@ export function agentRoutes( aiRoutingModel(routing, agentHarnessType(adapterType, config), model.id) === config.model)) { throw unprocessable("Choose a default model available on the selected connection"); } - // 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, binding.provider === "github"); - if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId, adapterType); + const testEnvironmentId = probe !== "none" || binding.provider === "github" + ? await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github") + : null; + if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId, adapterType); + // Healthy saved accounts were verified when connected. Configuration saves + // must not acquire a computer or send another hello prompt. Legacy adoption + // and explicit task adoption still need proof. Copilot retains its metadata-only + // packaged-runtime check because model availability is account-specific. + if (probe === "always" || (probe === "legacy" && selection.connection.config.aiLegacyAdoption === true) || binding.provider === "github") { const hasSavedAgent = !newAgent || (isUuidLike(agentId) && Boolean(await svc.getById(agentId))); - if (newAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") { + if (newAgent && probe === "none" && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") { // The lifecycle verifies the saved agent after its plugin can create the agent-owned computer. return { connectionId: selection.connection.id }; } @@ -3352,7 +3355,7 @@ export function agentRoutes( const adapter = requireServerAdapter(type); if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest, companyId); if (poolBinding?.mode === "router") { - await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId || testingSetup); + await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, "none", !savedAgentId || testingSetup); res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] }); return; } @@ -4617,7 +4620,7 @@ export function agentRoutes( const requiresApproval = company.requireBoardApprovalForNewAgents; const status = requiresApproval ? "pending_approval" : "idle"; const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined; - const managedHireConnection = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined; + const managedHireConnection = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, "none", true) : undefined; const createdAgent = await svcLifecycle.requestHire( companyId, { @@ -4896,7 +4899,7 @@ export function agentRoutes( }); const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined; - const managedConnection = managedBinding && !setupId ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined; + const managedConnection = managedBinding && !setupId ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, "none", true) : undefined; const createdAgent = await svcLifecycle.requestHire( companyId, { @@ -5443,7 +5446,7 @@ export function agentRoutes( if (!setup.aiConnectionRequiresAdoption || !setup.aiConnection) throw conflict("The agent’s AI configuration changed. Reload the task and try again."); // Probe in the agent's execution environment without installing access. // The binding, install, audit, and card resolution commit together below. - const validatedConnection = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, true, true); + const validatedConnection = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, "always", true); if (validatedConnection?.connectionId !== connectionId) throw conflict("This is no longer the selected account. Reload the task and try again."); const completed = await intents.complete(interactionId, connectionId, userId, { validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection }, @@ -5709,7 +5712,7 @@ export function agentRoutes( const configuringSetup = isAgentSetupDraft(existing); await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, - !configuringSetup && (!runtimeSettings || changed), configuringSetup, Boolean(runtimeSettings)); + !configuringSetup && (!runtimeSettings || changed) ? "legacy" : "none", configuringSetup, Boolean(runtimeSettings)); } } if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;