perf(agents): save healthy account settings without a runtime probe (#18690)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The agent runtime page saves account, model, and environment
settings.
> - The save handler starts a computer and runs a live account test
before it writes the edit.
> - A slow computer or provider can make a healthy account save take
tens of seconds.
> - Saved account health, access, installation, and compatibility can be
checked without a runtime test.
> - This pull request uses those checks for ordinary configuration
saves.
> - Legacy adoption, task adoption, and Copilot model checks keep live
verification.
> - The benefit is a fast save while Test and execution retain runtime
checks.

## Linked Issues or Issue Description

**What happened?**

Saving a new AI account selection on an existing agent can take 20–30
seconds. Model edits can take the same path. The configuration PATCH
acquires a computer, prepares account credentials, runs a
provider/runtime probe, and releases the computer before it saves.

**Expected behavior**

A healthy installed account can be saved after current access and
compatibility checks. A save confirms persistence. Test verifies the
runtime. An unverified legacy account must still pass live adoption
checks.

**Steps to reproduce**

Run the PostgreSQL regression with a 20-second controlled delay at
computer allocation. On the original route, a successful save takes
20,191 ms and calls allocation and the runtime probe once. With the
first patch, the same fixture takes 51 ms; with the environment-guard
correction, it takes 37 ms and calls neither operation. Each timing has
one sample. This is a local fault experiment with real HTTP and
PostgreSQL. It is not a hosted latency measurement.

Related: #17760 addresses binding comparison and prospective probe
environment resolution. This change deliberately removes the live probe
from ordinary healthy-account saves. It keeps live checks for legacy and
task adoption. #17965 fixes the result of an explicit managed Runner
test. Neither change removes this save-time wait. #18687 optimizes a
different Apps configuration read.

## What Changed

- Replace the internal probe boolean with explicit none, legacy, and
always modes.
- Use legacy mode for ordinary configuration saves. Keep current account
health, access, installation, compatibility, environment, audit, and
revision checks.
- Keep the environment company check before the live-probe branch. Keep
the live task-adoption transaction and Copilot model checks.
- Add twelve real PostgreSQL save-route regressions, including
company-bound environment checks. Keep the existing inherited-sandbox
adoption fixture for legacy and task adoption.
- Cover healthy and legacy settings changes separately. Keep provider
rejection and reconnect tests on the legacy save path.
- Update the specification and feature/access maps to distinguish saving
from runtime verification.

The configuration route owns this policy. The account service still owns
account access and health. The existing environment test service still
owns live probes. No new endpoint, migration, dependency, background
job, or provider protocol is added.

## Verification

Base: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`.

- Original route with initial regressions: three failures and two
passes. Healthy saves reached computer allocation. The controlled
20-second baseline saved successfully but failed the no-allocation
assertion.
- Initial save/adoption/Copilot selection: 21 passed. The name filter
excluded 141 unrelated tests. The seven new cases cover persistence,
revision and activity records, model edits, roster edits and stale
retries, missing installation, revoked grants, legacy verification, and
controlled allocation latency.
- Explicit Test-route suite: 55 passed. The first attempt reached the
30-second import hook limit. The same suite passed with a 90-second hook
limit.
- Saved harness preflight suite: six passed. Account revocation,
compatibility, probe failure and target release checks remain.
- `node scripts/check-maps.mjs` and `git diff --check`: passed.
- Review revision: both full save/account suites passed, 96 tests total.
The controlled save took 37.08 ms with zero computer allocations and
zero provider probes. The five new environment checks passed after the
three foreign-environment cases failed against the first patch.
- Local full `pnpm -r typecheck`: unavailable because cargo is missing.
- Local full `pnpm build`: unavailable at the verified Copilot runtime
prerequisite. Server typecheck preparation also reaches missing cargo.
- Local full `pnpm test:run`: stopped after concurrent broad checks
exceeded the container memory limit. No full-suite pass is claimed.
Direct server TypeScript checking and its bounded retry were also killed
by the container memory limit. The first remote Typecheck + Release
Registry check passed; the next head still requires fresh CI.

Reproduce the controlled timing:

```sh
cd server
PAPERCLIP_CONFIG_SAVE_PROFILE_DELAY_MS=20000 pnpm exec vitest run src/__tests__/agent-configuration-save.test.ts --hookTimeout 90000
```

The fixture keeps real Express, authorization, account selection,
PostgreSQL persistence, revisions and audit writes. Only remote
allocation and the provider probe are controlled. The default tests do
not contact a provider. Successful configuration saves no longer depend
on either controlled operation. A later explicit Test or task must still
verify the actual runtime.

Review follow-up: the first Greptile review found that the new
healthy-account path skipped the environment company guard. The
corrected route checks the effective environment before it decides
whether to probe. Five real PostgreSQL cases cover foreign, same-company
and global environments, inherited defaults, and an existing account
binding. Against the first patch, the three foreign-environment cases
returned 200 and failed their expected 403 assertions; the two allowed
cases passed. Initial fixture attempts hit unique environment
constraints; the fixture now uses distinct sandbox rows and names.

The first CI server shard found 11 tests that required a live probe for
every save. Those expectations are changed to the documented
healthy/legacy split, with additional healthy-selection cases. The build
and Runner jobs were cancelled after this test failure. Their logs show
no separate compile or test verdict. No cancelled check is counted as a
pass.

Latest commit `6257e9e67f289e1c650a8b4c2b818fcca37e2800`: [CI run
41542980091](https://github.com/paperclipai/paperclip/actions/runs/41542980091)
passed typecheck, build, all test shards, Runner checks, and E2E. All 55
check runs are complete: 52 passed and 3 were skipped by their workflow
conditions. The commit status check also passed. [Greptile
review](https://github.com/paperclipai/paperclip/pull/18690#issuecomment-6590555746)
is 5/5 for this commit with no new findings. The earlier environment
ownership thread is resolved. These remote results complete the checks
that the local tool and memory limits prevented. The earlier local
failures remain recorded above.

## Risks

A healthy saved account can become invalid at the provider before the
next runtime use. Ordinary saves no longer detect that through a live
test. They still reject unhealthy, revoked, incompatible, or uninstalled
accounts in current storage. Explicit Test and execution retain provider
authentication and project-authentication checks. Legacy-account
adoption, task connection adoption, and Copilot account-specific model
checks keep their existing live validation.

There is no migration. Reverting the route change restores the earlier
synchronous probe behavior.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools and code execution.
This runtime does not expose the exact model ID, context-window size, or
reasoning setting. Those values are not inferred.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites; full-suite
limits are above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-03 16:56:11 -06:00
1 parent 049c1eacf5
commit dcc0878b32
7 files changed
+256 -24

No files matched your search

+7
View File
@@ -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
+10
View File
@@ -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
+6
View File
@@ -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,
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
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<ReturnType<typeof realEnvironmentTests>["resolveAdapterTestExecutionContext"]>();
const probe = vi.fn<ReturnType<typeof realEnvironmentTests>["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);
});
@@ -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<ReturnType<typeof fixture>>, 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 () => ({
+6 -1
View File
@@ -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");
+18 -15
View File
@@ -3176,7 +3176,7 @@ export function agentRoutes(
res.json(detected);
});
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, 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<string, unknown>, 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;