mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A new or reconfigured agent runs a harness verification (a hello probe in a test sandbox) before it is ready, and its runs wait until it passes > - In the final Daytona restart chaos wave, a Codex agent's runs waited about 417 s for their first sandbox, while boot took only about 32 s > - Each control-plane restart killed the verifier. The next process waited out the 120 s lifecycle lease and then booted a fresh sandbox. Nothing recorded a configuration that had already passed. > - This pull request records a passed verification by what makes it valid and reuses it, and settles a dead verifier's lease on the shared ownership path at once > - The benefit is that a restart never repeats a passed verification, and never leaves runs waiting out a dead process's lease ## Linked Issues or Issue Description **What happened?** In the final Daytona chaos wave, the Codex agent was created at 09:55:46 UTC and its three runs were queued. Verification ran four times, in four sandboxes, because faults K1, K4 and K2 each killed the verifying server. After K2 the server was ready at 10:00:20, but verification restarted only at 10:02:13: it waited out the 120 s lifecycle lease. The first run sandboxes were created at 10:02:43, 417 s after launch. **Steps to reproduce** 1. Create a Codex agent and assign it a task at once, so the task waits for harness verification. 2. Kill the control plane (SIGKILL) while the agent is verifying. 3. The restarted server waits out the dead verifier's 120 s lifecycle lease, then verifies again in a new test target. The task's first token arrives about 130 s after the fault. **Expected behavior** - A passed verification of the same effective configuration in the same runner image is never repeated. - A verification is never restarted from scratch while its holder process is alive. When the holder is proven gone, its lease is settled at once. - Runs gate only on a configuration that has never passed. **Root cause** `verify()` (`server/src/services/agent-harness-verification.ts`) leased a fresh sandbox on every attempt and recorded nothing on a pass. The lifecycle lease (`modules/agent-lifecycle/adapters/postgres.ts`) named no holder process, so a restart could only wait for it to expire. ## What Changed - Restart chaos TTFT driver: a `verify` phase, reported in its own rows, and a `--verify-fault K1|K2|K6` option. The fault fires 1 s after `verifying` is observed, so it interrupts a held lease. The seed keeps each agent's creation body, so the driver can hire an identical agent. - **Persisted pass:** new columns `agents.harness_verified_digest` and `harness_verified_at` (migration 0448). The digest is an HMAC, keyed with the instance's decision-signing secret, over: - the fully resolved effective configuration: every value the test used, including `cwd`, resolved credentials, and agent-over-environment precedence; - the selected AI account and its credential generation (connection and grant ids and `updatedAt`, never the credential); - the environment and the runner image. The key means the stored value cannot confirm a guessed credential offline. For a sandbox, the runner image is an immutable image digest or a qualified Daytona snapshot ID (`sandboxBinding`, now exported). For local runs it is the server version. A mutable image or snapshot name, or a local environment redirected to the managed sandbox, never reuses a pass. - **Server-only:** `normalizeAgentBaseRow` and `redactAgentRowForResponse` strip the digest and its timestamp from agent API responses. - `verify()` still runs every account, policy and secret check, then returns complete on a matching digest without booting a test target. It records the digest on a pass before the lifecycle step completes, so a restart in between reuses it. - **Lease holder:** the lifecycle `claim()` stamps the claiming controller process on the lease (`lifecycleOperation.leaseHolder`, supplied through the lifecycle effects port) and clears the stamp with the lease. The compare-and-swap release lives in the lifecycle module (`releaseAgentLifecycleLeaseOfHolderInTransaction`). - **Shared ownership path:** a new `agent_lifecycle_lease` kind with `settleAgentLifecycleLease`. - When the reconciler proves the holder gone, settle clears exactly that lease (compare-and-swap on the holder), notifies lifecycle work, and logs `agent.lifecycle_lease_reconciled`. - A live holder is never taken over. The lifecycle state, operation and progress are untouched. ## Verification - `restart-verification.test.ts` (embedded Postgres, real lifecycle store and worker, real reconciler pass): - A verification held by a dead process is claimed and completed at once after the startup pass, with the agent ready in under 10 s instead of waiting out the 120 s lease. It fails without the new kind. - A live holder is never taken over. - The claim stamps the holder, and `defer` clears it. - `agent-harness-verification-preflight.test.ts`: - A second `verify()` of the same configuration allocates no target. A revoked account still fails before reuse. A changed model, `cwd` or literal credential tests again. It fails without the reuse. - A failed verification records nothing. - Ownership report, all lifecycle module tests, remote, provider-API and unit harness verification tests, and crash-point call sites: 13 files, 220 tests pass. - Targeted local run: the TTFT driver's new `verify` phase (`--phases verify --verify-fault K1`, Codex, 2 samples) hires a new agent, assigns it a task at once, kills the control plane (K1) while it verifies, and times fault to first agent token: - Before (master server code): 129.8 s and 129.8 s. The restarted server waited out the dead verifier's 120 s lease. Run-log TTFT from run creation was only 4.1 s. - After (this branch): 15.8 s and 17.2 s. The startup reconciler pass settled `agent_lifecycle_lease` and verification re-ran at once. ## Risks - **A stale pass:** the digest covers every resolved value, the selected account generation, the environment and an immutable runner image, so any change tests again. Rotating the instance signing secret invalidates every digest, so each agent verifies once more. - **Migration:** two nullable columns, no backfill. Existing agents verify once more, then reuse. - **Settle:** it runs only on a `gone` proof, compare-and-swap on the exact holder. It never changes the lifecycle state. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file editing, test 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
15 KiB
TypeScript
200 lines
15 KiB
TypeScript
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 { verify };
|
|
}
|