Files
PaperClipAI/server/src/services/agent-harness-verification.ts
T
Devin FoleyandPaperclip 5a7e740d82 fix: validate managed AI accounts before verification allocation (#17384)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A saved agent must pass execution configuration checks before it can
run.
> - Managed AI account selection belongs to the existing company-scoped
account service.
> - Verification allocated its execution environment before it checked
that account.
> - A missing default or revoked account could create a sandbox on each
retry.
> - This pull request checks account selection before allocation and
keeps the final check.
> - Invalid account settings now fail before they cause avoidable
provider work.

## Linked Issues or Issue Description

**What happened?**

Saved-agent lifecycle verification allocates a test execution target
before `withManagedAiProbe` selects the managed AI account. An
unavailable account can cause sandbox creation and cleanup on every
retry.

**Expected behavior**

Check account compatibility, responsible-user access, default selection,
grant health and agent eligibility before target allocation. Check
current account access again before the real probe.

**Steps to reproduce**

1. Configure a saved OpenCode agent with an OpenRouter personal-default
binding.
2. Leave that user's OpenRouter default empty, or revoke the selected
grant.
3. Let lifecycle verification use a sandbox execution environment.
4. Before this change, verification can allocate the target and then
reject the account. After this change, account selection rejects the
configuration before allocation.

**Paperclip version or commit**

Source baseline `b5bf86ea09042a864b60910f80c8fdf7e64e9ef0`.

**Deployment mode**

Server with Postgres and a remote execution environment. Tests use
disposable local Postgres and a controlled target allocation boundary.

Searched public PRs and issues for verification, connection preflight,
probes and lifecycle work. No duplicate fix found. Related public issue
#16128 concerns credential probing and reauthorization. This change does
not alter reauthorization or credential refresh.

## What Changed

- Call the existing managed account selector with the resolved adapter
and model before allocating a verification execution target.
- Keep router resolution, secret handling, final account selection and
target release with their existing owners.
- Add six Postgres tests and document the saved-agent ordering contract.

## Verification

- Focused verification suites: 30 tests passed across two files.
- `pnpm build`: passed.
- `pnpm -r typecheck`: passed.
- Full PR CI passed on `ddc0879d46b60310c533e6d3c4b8acdd178d979c`: 54
successful checks and two expected skips. [Workflow
evidence](https://github.com/paperclipai/paperclip/actions/runs/40033261909).
This includes typecheck, tests, build and browser checks.
- Apex review: 5/5 on that exact head, clean check, no comments or
unresolved threads. Merge state is clean.
- Additional local `pnpm test:run` is still running. The complete local
suite is not yet a pass claim.
- The first focused run passed the missing-default case but failed five
setup hooks because fixtures reused a unique company prefix. Each
fixture now has a distinct prefix. The complete focused rerun passed.
- Tests use real account selection, memberships, grants, personal
defaults, encrypted fixture credentials and managed runtime preparation.
Only execution-target allocation and the provider probe are controlled
boundaries. No live provider request is required.
- Tests prove that missing defaults, revoked grants and incompatible
models do not allocate a target. A valid account still reaches the
managed probe. Revocation during allocation still fails the final
account check and releases the target. Probe failure also releases it.
- `git diff --check`: passed. Reviewed the diff for secrets and private
account data before push.
- No hosted login or production deployment is claimed for this
follow-up.

## Risks

- Adds one read-only account selection to each managed saved-agent
verification attempt. The selector can read company accounts and grants.
It makes no provider request and creates no execution target.
- Final selection stays fresh. A successful preflight does not reserve
an account or cache authorization.
- Missing configuration still follows the existing durable lifecycle
retry schedule. This change prevents needless allocation; it does not
remove those database retries.
- No schema, scheduler, provider-account choice, capacity or public API
changes.

## Model Used

OpenAI Codex, GPT-6 family. The exact deployment model ID and context
window are not exposed in this session. Used reasoning, repository tools
and code execution.

## 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 (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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>
2026-10-23 16:31:18 -07:00

98 lines
7.0 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { agents, companies, type Db } from "@paperclipai/db";
import type { AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
import { isForbiddenConfigEnvKey, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { requireServerAdapter } from "../adapters/index.js";
import { agentEnvironmentTestService } from "./agent-environment-test.js";
import { aiConnectionRouterService } from "./ai-connection-router.js";
import { aiConnectionService } from "./ai-connections.js";
import { withManagedAiProbe, stripAiAuthBindings } from "./ai-connection-runtime.js";
import { environmentService } from "./environments.js";
import { secretService } from "./secrets.js";
import { museRunnerBroker } from "./muse-runner-broker.js";
import { dotRunnerBroker } from "./dot-runner-broker.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import type { LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) {
const verified = result.checks.some(check => check.level === "info" &&
(/hello_probe_(passed|succeeded)$/.test(check.code) || check.code === "ai_connection_api_key_reverified" || check.code === "copilot_metadata_verified"));
const authenticationFailed = result.checks.some(check =>
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || check.code === "COPILOT_AUTH_REQUIRED" || /_hello_probe_auth_required$/.test(check.code));
const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe")));
if (result.status === "fail" || authenticationFailed || incomplete) {
const has = (suffix: string) => result.checks.some(check => check.code.endsWith(suffix));
const code = authenticationFailed ? "harness_auth_failed"
: has("_hello_probe_model_unavailable") || has("_model_invalid") ? "harness_model_unavailable"
: has("_command_unresolvable") ? "harness_command_unavailable"
: has("_cwd_invalid") ? "harness_cwd_invalid"
: has("_hello_probe_timed_out") ? "harness_probe_timed_out"
: has("_hello_probe_unexpected_output") ? "harness_probe_unexpected_output"
: "harness_test_failed";
throw Object.assign(new Error("The harness test failed"), { code });
}
}
export function agentHarnessVerificationService(db: Db, manager: PluginWorkerManager) {
const tests = agentEnvironmentTestService(db, manager);
const secrets = secretService(db);
const environments = environmentService(db);
async function verify(snapshot: LifecycleAgent): Promise<"complete" | "pending"> {
const [agent] = await db.select().from(agents).where(and(eq(agents.id, snapshot.id), eq(agents.lifecycleVersion, snapshot.lifecycleVersion)));
if (!agent) return "pending";
if (agent.adapterType === "paperclip_runner" && ["openai_dot", "grok_bot"].includes(String(agent.adapterConfig.provider))) {
const binding = await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id);
return binding?.status === "ready" && binding.wakeVerified && binding.id === agent.adapterConfig[agent.adapterConfig.provider === "grok_bot" ? "externalBindingId" : "dotBindingId"] ? "complete" : "pending";
}
if (agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse") {
const binding = await museRunnerBroker(db).bindingForAgent(agent.companyId,agent.id);
return binding?.status === "ready" && binding.paired && binding.receiverDetected && binding.backgroundReplyVerified && binding.id === agent.adapterConfig.museBindingId ? "complete" : "pending";
}
const [company] = await db.select().from(companies).where(eq(companies.id, agent.companyId));
const responsibleUserId = agent.lifecycleOperation?.responsibleUserId ?? company.defaultResponsibleUserId;
const context = { consumerType: "agent" as const, consumerId: agent.id, actorType: "system" as const,
actorId: "agent-lifecycle", responsibleUserId };
let binding = agent.runtimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
let config = agent.adapterConfig;
if (binding?.mode === "router") {
const selected = await aiConnectionRouterService(db, manager).resolve({ companyId: agent.companyId,
poolId: binding.connectionId, agentId: agent.id, userId: responsibleUserId,
adapterType: agent.adapterType, taskKey: `lifecycle:${agent.lifecycleOperation!.id}` });
binding = selected.binding;
config = { ...config, ...selected.runtimeConfig };
}
if (binding) config = { ...config, env: stripAiAuthBindings(config.env) };
config = (await secrets.resolveAdapterConfigForRuntime(agent.companyId, config, context, { adapterType: agent.adapterType })).config;
const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId, agent.adapterType === "paperclip_runner" && config.provider === "acpx" && config.acpxAgent === "copilot");
if (environmentId) {
await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId, agent.adapterType);
const environment = await environments.getById(environmentId);
const env = Object.fromEntries(Object.entries(binding ? stripAiAuthBindings(environment?.envVars) : parseObject(environment?.envVars))
.filter(([key]) => !isForbiddenConfigEnvKey(key)));
const resolved = await secrets.resolveEnvBindings(agent.companyId, env,
{ ...context, consumerType: "environment", consumerId: environmentId });
config = { ...config, env: { ...resolved.env, ...parseObject(config.env) } };
}
if (binding) await aiConnectionService(db).select({
companyId: agent.companyId, userId: responsibleUserId, agentId: agent.id,
adapterType: agent.adapterType, model: config.model, runnerProvider: config.provider,
acpxAgent: config.acpxAgent, binding,
});
const target = await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
let status: "released" | "failed" = "failed";
try {
if (target.fallbackChecks.length) throw Object.assign(new Error("The test environment is unavailable"), { code: "test_environment_unavailable" });
const input = { companyId: agent.companyId, adapterType: agent.adapterType, config,
executionTarget: target.executionTarget, environmentName: target.environmentName };
const result = binding ? await withManagedAiProbe(db, { ...input, agentId: agent.id, responsibleUserId, binding },
managed => tests.testManagedEnvironment(agent.adapterType, { ...input, config: managed.config }, binding!, managed, agent.id))
: await requireServerAdapter(agent.adapterType).testEnvironment(input);
assertHarnessTestPassed(result);
status = "released";
return "complete";
} finally { await target.release(status); }
}
return { verify };
}