mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## 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>
98 lines
7.0 KiB
TypeScript
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 };
|
|
}
|