mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip manages AI agents doing work for people. > - Agent verification must finish before an accepted task can start. > - A missing AI account default can prevent verification before a provider starts. > - Lifecycle policy treated that owned configuration refusal as a temporary failure and retried each minute. > - The accepted turn remained Queued without explaining the prerequisite. > - Park account configuration failures and reuse the task's existing inline connection card. > - Successful repair rechecks verification and releases the original queued turn under its original authority. ## Linked Issues or Issue Description **What happened?** An OpenCode agent selected the responsible user's OpenRouter default, but that default did not exist. Verification retried unchanged configuration indefinitely and its accepted task stayed Queued. **Expected behavior:** Preserve accepted work, explain the setup prerequisite, and let the appropriate person repair it using the existing task connection UI. Completion must verify the agent before starting the same queued turn. Genuine pause, approval and access gates remain effective. **Reproduction:** Create an OpenCode Runner agent with a responsible-user OpenRouter binding and no personal default; assign a task while verification is pending. The base retries verification and shows an unexplained queue. Related: #18702 handles pending task connection recovery after account repair. This PR handles the earlier agent-verification boundary. #20330 addresses a separate Codex governed-wait timeout and is already merged. ## What Changed - Share owned account-selection failure evidence through a pure library contract. Lifecycle policy parks those configuration failures with fixed, redacted guidance; temporary infrastructure errors retain their retries. - Batch setup-state reads for unstarted queued turns. Distinguish manual repair from automatic setup retries and preserve Stop, started/history state and lifecycle holds. - Reuse the existing `connection_intent` card, account selection and completion APIs. Its controller-only `agentSetup` marker accepts only never-started legacy turns; runtime tokens still require a running run. - Address the card to the lifecycle verifier's account owner, preserving the task's execution principal and private-task visibility. Attach missing cards on admission, verification, startup and full recovery, including retained retries and continuations. Repeated delivery and publication remain bounded. A new queued turn supersedes the setup card of a stopped turn; generic connection cards retain cross-run reuse. - Recheck lifecycle verification after usable-account completion, then release the existing turn through the normal readiness path. Preserve decline, Stop, reassignment and changed setup-owner gates. Fence an in-flight verification failure from overwriting a repair; add no provider wake or recurring worker. - Park owned account refusals during resume as well as initial verification. Bind setup cards to the lifecycle operation, settle obsolete deliveries, and keep explicit Retry guidance accurate. - Reuse the router’s existing lifecycle pin for setup repair without rerouting the task, advancing the pool cursor, or replacing its saved binding. Legacy direct-account cards remain readable. - Hide the incorrect Resume banner during setup, retaining genuine manual/budget pause controls. Document the execution and retry contract. ## Verification - Red: eight original behavior assertions failed on the base, including the real selector's retry decision and the real batched queue projection. Six owned selection cases failed classification. Temporary-error presentation, different task/verifier users, retained retry/continuation recovery and the setup Resume banner also have failing-before/passing-after regressions. - Green: 430 affected backend, lifecycle, shared-contract and UI tests passed before the final review corrections. Subsequent focused runs cover one inline card, real account selection/completion, duplicate delivery/publication, verification before readiness, unchanged run authority, private tasks, no extra provider wake, decline/Stop/reassignment, company isolation, invalid runtime tokens, transient retries and redacted diagnostics. - Final setup/card and generic connection-service suites: 49 tests passed. The stopped-turn case failed before the correction, then passed with real account completion, the stopped source remaining cancelled, and the replacement source remaining queued. - After rebase, all 129 focused assertions passed. The local queue suite’s 88 execution assertions passed repeatedly, but its database-fixture teardown exceeded both 30-second and 120-second hooks. An isolated queue case completed, and the setup/connection suites fully passed. The original queue test and cleanup remain unchanged; broad CI supplies the full-suite result. - Server/UI typechecks, module boundaries, token gates and diff checks passed. `pnpm verify --explain` requested broad CI because the execution-contract document changed; no redundant full local suite was substituted. - Real source acceptance: macOS arm64, Node 26.4.0, isolated embedded PostgreSQL and local board identity. Created an OpenCode/OpenRouter agent and task through the API with no default. Admission retained one unstarted turn and produced the existing inline Connect OpenRouter card. The browser opened its credential form and closed it without entering credentials. The same card/run persisted after server restart and reload, with the setup label and no incorrect Resume. The browser also reproduced Stop followed by a new queued turn retaining the stale card on the prior head. On `12f609c98372fd1e50644028db75cfcc8deb210d`, startup superseded the stale card and retained the same replacement turn, with the original turn still cancelled and no provider execution. - The earlier CI case-search failure also reproduces on exact base `0e3fdebea8257bdff11573081c76b306f12db230` without this PR. Its repair is now in master via #20371; this branch is rebased onto it and contains no API-search changes. - Staging's actual Runtime test returned “Connect an account and choose your personal default” before provider startup. The original turn later started independently on deployed code, then hit a separate workspace-export failure. Automatic file recovery subsequently marked that run successful; its task remains Blocked and the trial’s useful output is unverified. This PR is not deployed; provider authentication through its card and downstream publication remain unverified. No account, credential, default, permission or binding was changed during diagnosis. - Previous revision review: Greptile 5/5 on `12f609c98372fd1e50644028db75cfcc8deb210d`, with all four inline findings addressed and resolved. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42713494508) on that revision: 59 successful checks, one neutral check and three intentional skips, with no pending or failed checks. - Latest revision `976bcae993805a8a2222395bbff9394f6791b424`: resume, router and Retry-copy regressions failed before repair. Green: 92 lifecycle/setup/shared tests, 83 setup/generic-connection/router tests, and 137 UI/shared tests. Server/UI typechecks, module boundaries and token gates passed. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42720731137) on this exact revision: 59 successful checks and three intentional skips, with no pending or failed checks. Greptile reviewed this head at 5/5; all six review findings are addressed and resolved. Real Stop/retry acceptance retained the same replacement turn and current-operation card through two actual server restarts, with no provider execution. ## Risks - Known account configuration failures now require repair or explicit Retry. Temporary failures keep automatic retries; accepted work and authority remain intact. - The projection adds one company-scoped batch read only for unstarted queued turns. Card attachment examines at most 100 active candidates and adds no timer, worker, schema or migration. - The optional stored setup-card marker requires an aware reader during rollback. No run/session/checkpoint identity format changes. Setup presentation grants no access and cannot bypass verification or other gates. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, tool use, code editing and test execution. The exact serving model identifier and context-window size are not exposed in this session. ## 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>
211 lines
16 KiB
TypeScript
211 lines
16 KiB
TypeScript
import { connectionIntentService } from "./connection-intents.js";
|
|
import { readAiConnectionConfigurationFailure } from "./ai-connection-configuration-failure.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { createHmac } from "node:crypto";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { agents, companies, companySecrets, 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 { PLUGIN_RUNNER_AI_CONNECTION_REQUIRED, pluginEnvironmentRunnerTaskDriver, pluginRunnerAiConnectionRequiredFailure } from "./plugin-runner-ai-connection.js";
|
|
import { environmentService } from "./environments.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
|
|
import { museRunnerBroker } from "./muse-runner-broker.js";
|
|
import { RUNNER_REVISION_MISMATCH_CODE } from "./runner-revision-mismatch.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";
|
|
import { resolveDecisionSigningSecret } from "./decision-signing.js";
|
|
import { sandboxBinding } from "./environment-probe.js";
|
|
import { serverVersion } from "../version.js";
|
|
|
|
/**
|
|
* Digest of what a passed harness test proves: the agent's fully resolved
|
|
* effective configuration (every value the test used, including cwd and
|
|
* resolved credentials), the AI account and credential generation it selected,
|
|
* its environment, and the runner image it ran in. Keyed with the instance's
|
|
* signing secret, so the stored value never verifies a guessed credential
|
|
* offline. Null when the runner image has no immutable identity: such a pass
|
|
* is never reused.
|
|
*/
|
|
export function harnessVerificationDigest(input: {
|
|
adapterType: string; config: Record<string, unknown>; account: unknown; environmentId: string | null;
|
|
runner: Record<string, unknown> | null;
|
|
}): string | null {
|
|
if (!input.runner) return null;
|
|
const canonical = JSON.stringify({ v: 2, adapterType: input.adapterType, config: input.config, account: input.account ?? null,
|
|
environmentId: input.environmentId, runner: input.runner }, (_key, value: unknown) =>
|
|
value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)
|
|
? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))) : value);
|
|
return `v2:hmac-sha256:${createHmac("sha256", resolveDecisionSigningSecret()).update("harness-verification-v2:").update(canonical).digest("hex")}`;
|
|
}
|
|
|
|
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"
|
|
|| (result.adapterType === "paperclip_runner" && check.code === "cursor_cloud_account_ready")));
|
|
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 = result.checks.some(check => check.code === PLUGIN_RUNNER_AI_CONNECTION_REQUIRED) ? PLUGIN_RUNNER_AI_CONNECTION_REQUIRED
|
|
: result.checks.some(check => check.code === RUNNER_REVISION_MISMATCH_CODE) ? RUNNER_REVISION_MISMATCH_CODE
|
|
: result.checks.some(check => check.level === "error" && check.retryable === true) ? "test_environment_unavailable"
|
|
: 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" | "waiting"> {
|
|
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);
|
|
// A successful read of these external facts has no retry deadline. The
|
|
// real confirmation/configuration producer rechecks this phase durably.
|
|
return binding?.status === "ready" && binding.paired && binding.receiverDetected && binding.backgroundReplyVerified && binding.id === agent.adapterConfig.museBindingId ? "complete" : "waiting";
|
|
}
|
|
const providerApi = resolveProviderApiPlacement({ agent });
|
|
if (providerApi) assertProviderApiPlacementPolicy(await instanceSettingsService(db).getGeneral());
|
|
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;
|
|
let runner: Record<string, unknown> | null = providerApi ? { kind: "provider_api" } : { kind: "local", version: serverVersion };
|
|
const environmentId = providerApi ? null : 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);
|
|
if (environment && environment.driver !== "local") {
|
|
const image = environment.driver === "sandbox" ? await sandboxBinding(db, environment, agent.companyId).catch(() => null) : null;
|
|
// Only an immutable image digest names the image a test ran in; a snapshot
|
|
// name may be re-pointed without the allocation pinning it, so it never counts.
|
|
const immutable = image && "image" in image;
|
|
runner = immutable ? { kind: "sandbox", environmentId, ...image } : null;
|
|
} else if (environment && (await instanceSettingsService(db).getExperimental()).enableManagedSandboxOnly === true) {
|
|
// The test runs in the managed sandbox instead, whose image is not named here.
|
|
runner = null;
|
|
}
|
|
// Run admission refuses these agents before any lease, so verification must too.
|
|
const preconfiguredHome = agent.adapterType === "paperclip_runner" && config.homeMode === "preconfigured";
|
|
if (agent.adapterType === "paperclip_runner" && environment && (preconfiguredHome || !binding)
|
|
&& await pluginEnvironmentRunnerTaskDriver(db, environment)) throw pluginRunnerAiConnectionRequiredFailure(preconfiguredHome);
|
|
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) } };
|
|
}
|
|
const selectAccount = () => aiConnectionService(db).select({
|
|
companyId: agent.companyId, userId: responsibleUserId, agentId: agent.id,
|
|
adapterType: agent.adapterType, model: config.model, runnerProvider: config.provider,
|
|
acpxAgent: config.acpxAgent, binding: binding!,
|
|
});
|
|
const selected = binding ? await selectAccount() : null;
|
|
// The account and credential generation the binding selected, never the
|
|
// credential: the selected connection and grant, and the current version
|
|
// (and AI session epoch) of every secret its credential reads. Timestamps
|
|
// that change on every use are left out, so a pass stays reusable.
|
|
const connection = selected?.connection;
|
|
const credentialRefs = [...(connection?.credentialSecretRefs ?? []), ...(selected?.grant?.credentialSecretRefs ?? [])];
|
|
const credentialSecretIds = [...new Set(credentialRefs.map(ref => ref.secretId))];
|
|
const readCredentialVersions = async () => credentialSecretIds.length ? await db.select({ id: companySecrets.id,
|
|
latestVersion: companySecrets.latestVersion, aiSessionEpoch: companySecrets.aiSessionEpoch })
|
|
.from(companySecrets).where(and(eq(companySecrets.companyId, agent.companyId), inArray(companySecrets.id, credentialSecretIds)))
|
|
.then(rows => rows.sort((a, b) => a.id.localeCompare(b.id))) : [];
|
|
const accountWith = (credentialVersions: Awaited<ReturnType<typeof readCredentialVersions>>) => selected ? { ...selected.attribution,
|
|
credentialSecretRefs: credentialRefs, externalCredential: connection?.externalCredential ?? null,
|
|
credentialVersions } : null;
|
|
const credentialVersions = await readCredentialVersions();
|
|
// A configuration that already passed in this runner image is verified:
|
|
// a restart or a return to verifying never boots another test sandbox.
|
|
// A local probe without a managed AI connection can use the host's own
|
|
// credentials (environment variables, a CLI login) that no saved
|
|
// configuration names, so such a pass is never reused.
|
|
if (runner?.kind === "local" && !binding) runner = null;
|
|
const digest = harnessVerificationDigest({ adapterType: agent.adapterType, config, account: accountWith(credentialVersions), environmentId, runner });
|
|
if (digest && agent.harnessVerifiedDigest === digest) return "complete";
|
|
const target = providerApi
|
|
? { executionTarget: null, environmentName: null, fallbackChecks: [], release: async (_status?: "released" | "failed") => {} }
|
|
: await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
|
|
let status: "released" | "failed" = "failed";
|
|
// A holder, so the account the probe callback records survives control-flow narrowing.
|
|
const probed: { account: { connectionId: string; grantId: string } | null } = { account: null };
|
|
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 => {
|
|
// The account this probe actually uses; the recorded digest must describe it.
|
|
probed.account = { connectionId: managed.attribution.connectionId, grantId: managed.attribution.grantId };
|
|
return tests.testManagedEnvironment(agent.adapterType, { ...input, config: managed.config }, binding!, managed, agent.id);
|
|
})
|
|
: await requireServerAdapter(agent.adapterType).testEnvironment(input);
|
|
assertHarnessTestPassed(result);
|
|
status = "released";
|
|
// A passing subscription probe can save the credential it refreshed, which
|
|
// advances the version but keeps the AI session epoch; a person's rotation
|
|
// bumps the epoch. Record the generation the probe left, unless any epoch
|
|
// moved: then the pass is not recorded and the next check tests again.
|
|
const verifiedVersions = await readCredentialVersions();
|
|
const sameEpochs = verifiedVersions.length === credentialVersions.length && verifiedVersions.every((row, index) =>
|
|
row.id === credentialVersions[index]!.id && row.aiSessionEpoch === credentialVersions[index]!.aiSessionEpoch);
|
|
// The managed probe selects the account again when it starts. Record the
|
|
// pass only for the account it actually tested.
|
|
const sameAccount = !selected || (probed.account?.connectionId === selected.attribution.connectionId
|
|
&& probed.account?.grantId === selected.attribution.grantId);
|
|
const verifiedDigest = digest && sameEpochs && sameAccount
|
|
? harnessVerificationDigest({ adapterType: agent.adapterType, config, account: accountWith(verifiedVersions), environmentId, runner })
|
|
: null;
|
|
// Record the pass before the lifecycle step completes, so a restart in
|
|
// between reuses it instead of testing again.
|
|
if (verifiedDigest) await db.update(agents).set({ harnessVerifiedDigest: verifiedDigest, harnessVerifiedAt: new Date() })
|
|
.where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, snapshot.lifecycleVersion)));
|
|
return "complete";
|
|
} finally { await target.release(status); }
|
|
}
|
|
return { async verify(snapshot: LifecycleAgent) {
|
|
try { return await verify(snapshot); }
|
|
catch (error) {
|
|
if (readAiConnectionConfigurationFailure(error)) await connectionIntentService(db)
|
|
.requestForQueuedAgentSetup(snapshot.companyId, snapshot.id)
|
|
.catch(() => logger.warn({ agentId: snapshot.id }, "Could not attach queued agent setup connection request"));
|
|
throw error;
|
|
}
|
|
} };
|
|
}
|