Files
PaperClipAI/server/src/services/environment-probe.ts
T
DottaandClaude Opus 5.5 96badeaa42 fix(lifecycle): never repeat a passed harness verification; settle a dead verifier's lease at once (#20030)
## 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>
2026-11-08 08:10:40 -06:00

336 lines
17 KiB
TypeScript

import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared";
import type { Db } from "@paperclipai/db";
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
import {
parseEnvironmentDriverConfig,
resolveEnvironmentDriverConfigForRuntime,
type ParsedEnvironmentConfig,
} from "./environment-config.js";
import os from "node:os";
import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provider-runtime.js";
import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { environmentService } from "./environments.js";
import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js";
import type { SandboxEnvironmentConfig } from "@paperclipai/shared";
import { isQualifiableDaytonaSnapshot, qualifiedSandboxSnapshotId, readSnapshotIdentity, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js";
export const SANDBOX_PLATFORM_PROBE_COMMAND = "uname -s; uname -m; if version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$version\" ]; then printf 'glibc\\n%s\\n' \"$version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
type PlatformFacts = { platform: string; architecture: string; libc: "glibc" | "musl" | "unknown"; glibcVersion?: string };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function fingerprint(config: Record<string, unknown>): string {
return fingerprintEnvironmentSandboxProviderConfig(config as SandboxEnvironmentConfig);
}
function isImmutableImage(value: unknown): value is string {
return typeof value === "string" && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(value);
}
export function parseSandboxPlatformFacts(output: string): PlatformFacts {
const [os, arch, libc, version] = output.trim().split(/\r?\n/);
return { platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown",
architecture: arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : "unknown",
libc: libc === "glibc" || libc === "musl" ? libc : "unknown",
...(libc === "glibc" ? { glibcVersion: version?.trim() ?? "unknown" } : {}) };
}
/** The immutable runner image or snapshot identity of a sandbox environment, or null. */
export async function sandboxBinding(db: Db, environment: Environment, companyId: string) {
const selectedTemplate: { value: SandboxTemplateIdentity } = { value: null };
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment, {
applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true,
onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; },
});
if (parsed.driver !== "sandbox") return null;
const image = parsed.config.image;
const template = selectedTemplate.value;
if (isQualifiableDaytonaSnapshot(parsed.config, template)) {
return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config),
snapshot: parsed.config.snapshot, template, qualifiedSnapshotId: qualifiedSandboxSnapshotId(environment, companyId, parsed.config, template) };
}
if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key])
|| (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null;
return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record<string, unknown>), image, template };
}
export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise<PlatformFacts | null> {
const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]);
if (![1, 2].includes(Number(proof.v)) || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null;
const binding = await sandboxBinding(db, environment, companyId);
if (!binding || (proof.v === 2 ? !("qualifiedSnapshotId" in binding && binding.qualifiedSnapshotId)
: !("image" in binding) || proof.image !== binding.image)
|| proof.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint
|| JSON.stringify(proof.template) !== JSON.stringify(binding.template)) return null;
const facts = record(proof.facts);
if (typeof facts.platform !== "string" || typeof facts.architecture !== "string"
|| !["glibc", "musl", "unknown"].includes(String(facts.libc))) return null;
return { platform: facts.platform, architecture: facts.architecture, libc: facts.libc as PlatformFacts["libc"],
...(typeof facts.glibcVersion === "string" ? { glibcVersion: facts.glibcVersion } : {}) };
}
export async function probeEnvironment(
db: Db,
environment: Environment,
options: {
companyId?: string | null;
pluginWorkerManager?: PluginWorkerManager;
resolvedConfig?: ParsedEnvironmentConfig;
applyCustomImageTemplate?: boolean;
acquireSandboxRuntimeLease?: boolean;
assertCompanyBinding?: boolean;
} = {},
): Promise<EnvironmentProbeResult> {
const resolvedCompanyId = options.companyId ?? null;
const savedSandboxProbe = environment.driver === "sandbox" && options.acquireSandboxRuntimeLease === true && resolvedCompanyId;
const environments = environmentService(db);
if (savedSandboxProbe) await environments.recordSandboxPlatformProbe({ environmentId: environment.id,
companyId: savedSandboxProbe, config: environment.config, proof: null });
const parsed = options.resolvedConfig ?? (
options.acquireSandboxRuntimeLease === true
? parseEnvironmentDriverConfig(environment)
: resolvedCompanyId || options.applyCustomImageTemplate === true
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, {
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
})
: parseEnvironmentDriverConfig(environment)
);
if (parsed.driver === "local") {
return {
ok: true,
driver: "local",
summary: "Local environment is available on this Paperclip host.",
details: {
hostname: os.hostname(),
cwd: process.cwd(),
},
};
}
if (parsed.driver === "sandbox") {
if (options.acquireSandboxRuntimeLease) {
if (!resolvedCompanyId) {
return {
ok: false,
driver: "sandbox",
summary: "Sandbox environment probe requires a companyId context.",
details: {
provider: parsed.config.provider,
},
};
}
const runtime = environmentRuntimeService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const probeEnvironmentConfig = {
...environment,
config: {
...(environment.config ?? {}),
// Test probes should prove a fresh provider boot, not resume a retained
// agent lease and report success without provider-side activity.
reuseLease: false,
// Keep the probe sandbox inspectable in the provider dashboard
// (archived, provider-side expiry) instead of deleting it the moment
// the probe finishes.
archiveOnRelease: true,
},
};
let leaseRecord: Awaited<ReturnType<typeof runtime.acquireRunLease>> | null = null;
let releaseStatus: "released" | "failed" = "released";
let qualification: Record<string, unknown> | null = null;
let details: Record<string, unknown> | null = null;
try {
leaseRecord = await runtime.acquireRunLease({
companyId: resolvedCompanyId,
environment: probeEnvironmentConfig,
issueId: null,
agentId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
adapterType: null,
resolveSnapshotIdentity: true,
assertCompanyBinding: options.assertCompanyBinding,
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
});
const metadata = leaseRecord.lease.metadata ?? {};
const provider = leaseRecord.lease.provider ?? parsed.config.provider;
const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0
? metadata.sandboxName.trim()
: null;
details = { provider, providerLeaseId: leaseRecord.lease.providerLeaseId, leaseId: leaseRecord.lease.id,
leasePolicy: leaseRecord.lease.leasePolicy, metadata, platformQualificationRecorded: false,
platformQualificationDiagnostic: "Platform qualification requires an immutable OCI image or a Daytona snapshot with a verified provider ID. Update the provider plugin and probe again, or select Legacy runner." };
if ((isImmutableImage(metadata.image) && !metadata.snapshot) || metadata.snapshotIdentity) {
try {
const binding = await sandboxBinding(db, environment, resolvedCompanyId);
const snapshotIdentity = binding && "snapshot" in binding ? readSnapshotIdentity(metadata.snapshotIdentity, binding.snapshot) : null;
if (binding && (("image" in binding && metadata.image === binding.image && !metadata.snapshot)
|| (snapshotIdentity && metadata.snapshot === binding.snapshot && provider === "daytona"))) {
const platform = await runtime.execute({ environment: probeEnvironmentConfig, lease: leaseRecord.lease,
command: "sh", args: ["-c", SANDBOX_PLATFORM_PROBE_COMMAND],
bypassSession: true, timeoutMs: 10_000, commandTimeoutMs: 10_000 });
if (platform.exitCode !== 0) throw new Error("platform_probe_failed");
const facts = parseSandboxPlatformFacts(platform.stdout);
details.platform = facts;
if (facts.platform !== "unknown" && facts.architecture !== "unknown"
&& (facts.platform !== "linux" || facts.libc !== "unknown")
&& (facts.libc !== "glibc" || /^(?:glibc\s+)?\d+\.\d+(?:\.\d+)?$/.test(facts.glibcVersion ?? ""))) {
const { qualifiedSnapshotId: _qualifiedSnapshotId, ...recordedBinding } = { qualifiedSnapshotId: null, ...binding };
qualification = { v: snapshotIdentity ? 2 : 1, companyId: resolvedCompanyId, ...recordedBinding,
...(snapshotIdentity ? { snapshotIdentity } : {}), facts, probedAt: new Date().toISOString() };
delete details.platformQualificationDiagnostic;
} else details.platformQualificationDiagnostic = "The sandbox OS, architecture or libc could not be identified. Legacy remains the creation default.";
} else details.platformQualificationDiagnostic = "The live lease does not match the selected image or snapshot identity. Probe again.";
} catch {
details.platformQualificationDiagnostic = "Platform qualification could not complete; connection readiness does not establish native compatibility. Check the image and probe again.";
}
}
return {
ok: true,
driver: "sandbox",
summary: sandboxName
? `Connected to ${provider} sandbox ${sandboxName}.`
: `Connected to ${provider} sandbox environment.`,
details,
};
} catch (error) {
releaseStatus = "failed";
return {
ok: false,
driver: "sandbox",
summary: `Sandbox environment probe failed for provider "${parsed.config.provider}".`,
details: {
provider: parsed.config.provider,
error: error instanceof Error ? error.message : String(error),
},
};
} finally {
if (leaseRecord) {
const driver = runtime.getDriver(environment.driver);
try {
const releasedLease = await driver?.releaseRunLease({
environment: probeEnvironmentConfig,
lease: leaseRecord.lease,
status: releaseStatus,
});
if (qualification && driver && savedSandboxProbe
&& releasedLease?.id === leaseRecord.lease.id
&& releasedLease.companyId === resolvedCompanyId
&& releasedLease.environmentId === environment.id
&& releasedLease.status === "released" && releasedLease.cleanupStatus === "success") {
const recorded = await environments.recordSandboxPlatformProbe({ environmentId: environment.id, companyId: savedSandboxProbe,
config: environment.config, proof: qualification });
if (details) {
details.platformQualificationRecorded = recorded;
if (!recorded) details.platformQualificationDiagnostic = "The environment changed during the probe. Probe its current configuration again.";
}
} else if (qualification && details) {
details.platformQualificationDiagnostic = "The probe lease cleanup could not be verified. Resolve pending cleanup and probe again.";
}
} catch (releaseError) {
// Cleanup failures must not mask the connection result shown to
// the operator, but a leaked sandbox should still be traceable.
// eslint-disable-next-line no-console
console.warn(
`[environment-probe] Failed to release lease ${leaseRecord.lease.id} for provider "${parsed.config.provider}": ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`,
);
}
}
}
}
if (!isBuiltinSandboxProvider(parsed.config.provider)) {
if (!options.pluginWorkerManager) {
return {
ok: false,
driver: "sandbox",
summary: `Sandbox provider "${parsed.config.provider}" requires a running provider plugin.`,
details: {
provider: parsed.config.provider,
},
};
}
return await probePluginSandboxProviderDriver({
db,
workerManager: options.pluginWorkerManager,
companyId: resolvedCompanyId ?? "instance",
environmentId: environment.id,
provider: parsed.config.provider,
config: parsed.config as unknown as Record<string, unknown>,
});
}
return await probeSandboxProvider(parsed.config);
}
if (parsed.driver === "plugin") {
if (!options.pluginWorkerManager) {
return {
ok: false,
driver: "plugin",
summary: `Plugin environment probes require a plugin worker manager for "${parsed.config.pluginKey}:${parsed.config.driverKey}".`,
details: {
pluginKey: parsed.config.pluginKey,
driverKey: parsed.config.driverKey,
},
};
}
return await probePluginEnvironmentDriver({
db,
workerManager: options.pluginWorkerManager,
companyId: resolvedCompanyId ?? "instance",
environmentId: environment.id,
config: parsed.config,
});
}
try {
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
return {
ok: true,
driver: "ssh",
summary: `Connected to ${parsed.config.username}@${parsed.config.host} and verified the remote workspace path.`,
details: {
host: parsed.config.host,
port: parsed.config.port,
username: parsed.config.username,
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
remoteCwd,
},
};
} catch (error) {
const stderr =
error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
? error.stderr.trim()
: "";
const stdout =
error && typeof error === "object" && "stdout" in error && typeof error.stdout === "string"
? error.stdout.trim()
: "";
const code =
error && typeof error === "object" && "code" in error
? (error as { code?: unknown }).code
: null;
const message =
stderr ||
stdout ||
(error instanceof Error ? error.message : String(error)) ||
"SSH probe failed.";
return {
ok: false,
driver: "ssh",
summary: `SSH probe failed for ${parsed.config.username}@${parsed.config.host}.`,
details: {
host: parsed.config.host,
port: parsed.config.port,
username: parsed.config.username,
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
error: message,
code,
},
};
}
}