diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 6f62b97d78c..7f793c3f58f 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -1709,3 +1709,21 @@ Docker context/native compilation control: [38622133915](https://github.com/paperclipai/paperclip/actions/runs/38622133915). The behavior PR checklist now reflects those completed gates. This does not qualify or publish the still-local platform image consumer. + + +### Hosted named-snapshot repair — 2026-10-15 + +The failed UI journey is retained unchanged. The correction extends explicit +Claude Connect qualification to Daytona snapshots: the plugin resolves a stable +provider ID and creates by that ID, the server measures the actual sandbox and +records v2 company/config/account/template-bound proof after owned cleanup, and +native acquisition pins that same ID. Existing image v1 readers remain intact; +ordinary Legacy acquisition and Codex setup eligibility are unchanged. Discovery +performs no new provider calls. Lookup timeout cannot continue into allocation. + +A no-prompt live check with the pinned Daytona SDK 0.203.0 created an owned sandbox +from the managed snapshot ID, measured Linux x86_64 / glibc 2.41, and deleted it. +Receipt: `/private/tmp/claude-test-deployment-20261015/snapshot-id-live-probe.json`. +This is provider identity/architecture evidence, not native Claude task proof. +The new candidate still needs cloud checks, deployment, and a fresh ordinary UI +journey through creation, useful task, revision and downloaded artifacts. diff --git a/maps/agent-access-map/execution-environments.md b/maps/agent-access-map/execution-environments.md index 5c2b2583167..1a5e97fc0a2 100644 --- a/maps/agent-access-map/execution-environments.md +++ b/maps/agent-access-map/execution-environments.md @@ -95,7 +95,8 @@ Both probe endpoints require a board actor that is local-implicit or an instance administrator; ordinary agent identity and company membership alone are denied. The saved sandbox probe also resolves company/environment and secret access before allocating a fresh lease. Its measurement is company-bound to an immutable -image digest, effective provider configuration and active custom-image identity; +image digest or verified Daytona snapshot ID, effective provider configuration +and active custom-image identity; only confirmed cleanup permits recording it. A draft probe does not record saved qualification. Reading stored qualification does not lease/provision a target and does not grant an agent permission to probe or manage that target. @@ -117,7 +118,8 @@ at the scoped commit. A connection-only success or a fixture cannot certify native compatibility, account authentication or agent access. Scoped Verification: `not-run` — on one authorized owned target, compare pinned -image proof with mutable/snapshot and changed-config controls; require independent +image and provider snapshot-ID proof with mutable-image, unverified-snapshot +and changed-config/account controls; require independent cleanup evidence before recording proof. Assert agent/non-admin board denial, secret-context denial where the actor lacks access and forged-proof rejection. Verify API reads hide the private record and metadata-only updates cannot overwrite it. Then use diff --git a/maps/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md index 00d59b7dcb8..a3f1a5d4e54 100644 --- a/maps/feature-map/execution-environments.md +++ b/maps/feature-map/execution-environments.md @@ -42,8 +42,8 @@ Automated: [environment UI](../../ui/src/pages/CompanyEnvironments.test.tsx), [d Manual: Save a disposable target, reload it, and run its available probe. Verify both success and missing-credential/reachability errors identify the target. Confirm unavailable providers are not offered as working run targets. -For an immutable sandbox image, pin the OCI image with `@sha256` and explicitly -probe the saved environment in the intended company. The existing connection +For an immutable sandbox image, pin the OCI image with `@sha256`, or select +a Daytona snapshot whose provider ID can be verified, and explicitly probe the saved environment in the intended company. The existing connection probe allocates a fresh lease and can measure its OS, architecture and libc. Qualification is recorded only when the selected image/configuration matches the live lease and cleanup is confirmed. Inspect `platformQualificationRecorded` @@ -52,7 +52,10 @@ the image has native runtime qualification. The measured record is server-owned, company-bound and tied to the effective configuration and active custom-image identity. Mutable tags, provider defaults -and name-only snapshots do not qualify. Changing the target or image invalidates +and snapshots without verified provider identities do not qualify. Daytona +qualification creates by provider ID and binds native execution to that same ID; +a deleted ID fails instead of resolving a replacement name. Changing the target, +account, image or active captured template invalidates the record; normal metadata edits cannot forge or overwrite it, and API reads omit the private metadata. Reading this evidence for discovery or creation does not allocate a sandbox. The draft `probe-config` checks are not a saved-image diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index 5428094bed0..1c15e75dfbc 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -68,14 +68,16 @@ Use [runner validation](./runs-adapters.md#adapter-setup) and [environment qualification](./execution-environments.md#environment-settings) for the prerequisites; a successful login alone is insufficient. -For a new Claude agent on an explicit immutable sandbox image, use Connect to +For a new Claude agent on an explicit immutable sandbox image or a Daytona +named snapshot, use Connect to qualify the selected environment through the existing permission-gated probe. Do not preprobe outside onboarding when claiming this first-use path. Fresh scoped discovery determines the runner for both the account test and hire; the measured Legacy result remains valid. An instance-admin denial, incomplete cleanup, missing lease or changed configuration stops setup before the adapter -test or hire. Named snapshots and mutable image tags do not gain qualification -through this flow. [Helper controls](../../ui/src/lib/qualify-claude-setup.test.ts) +test or hire. Daytona resolves the selected snapshot to its immutable provider +ID before creating the measured lease; native execution pins that same ID. +Mutable image tags and other providers’ named templates remain unqualified. [Helper controls](../../ui/src/lib/qualify-claude-setup.test.ts) and mounted wizard/new-agent tests use mocks; live managed acceptance remains a separate gate. For a new Codex agent with Auto on a saved immutable OCI sandbox, Connect first diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 7f937dd03e2..0a1799887c6 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -234,10 +234,26 @@ for an image pinned with `@sha256`, after confirmed lease cleanup. The record is server-owned and bound to the company, effective provider/image configuration and active custom-image identity. Creation and discovery read this record without allocating a sandbox. Configuration or image changes invalidate it. Mutable tags, -provider defaults and name-only snapshots do not establish compatibility; they -keep automatic Legacy selection until there is verifiable immutable image evidence. +provider defaults and unverified snapshot names do not establish compatibility; +they keep automatic Legacy selection until there is verifiable immutable image +or Daytona snapshot-ID evidence. See managed snapshot qualification below. A connection-only success does not establish native readiness. +### Managed Daytona snapshot qualification + +Claude's automatic Connect flow can qualify a named Daytona snapshot through the +existing administrator-only saved environment probe. The bundled Daytona plugin +resolves the name to its provider snapshot ID, creates the probe sandbox by ID, +and returns its account and creation identity. OS, architecture and libc facts +are recorded only after confirmed cleanup. Native execution uses that same +snapshot ID; discovery does not allocate or perform provider lookups. A deleted +snapshot fails at the provider rather than silently selecting a replacement with +the same name. Configuration, resolved credentials and active captured-template +changes require another probe. Other named provider templates and mutable OCI +tags remain unqualified. A provider plugin without snapshot identity support +returns an actionable setup failure; it cannot certify a native default. + + On Linux, Codex's sandboxed filesystem modes also require the host to permit its unprivileged namespaces. A no-privilege Docker container can prohibit nested namespace creation even when installation and binary compatibility pass. diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 362021051dc..5ca423cf020 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -141,6 +141,50 @@ describe("Daytona sandbox provider plugin", () => { __resetDaytonaSandboxHandleCacheForTest(); }); + const snapshotRecord = { id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: "managed-snapshot", organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + const snapshotProbe = { driverKey: "daytona", companyId: "company-1", environmentId: "env-1", runId: "probe-1", + resolveSnapshotIdentity: true, config: { apiKey: "fixture-key", snapshot: "managed-snapshot", reuseLease: false } }; + + it("qualifies a named snapshot by creating from its immutable provider ID", async () => { + mockSnapshotGet.mockResolvedValue(snapshotRecord); + mockCreate.mockResolvedValue(createMockSandbox()); + const lease = await plugin.definition.onEnvironmentAcquireLease!(snapshotProbe); + expect(mockSnapshotGet).toHaveBeenCalledWith("managed-snapshot"); + expect(mockCreate.mock.calls[0][0].snapshot).toBe(snapshotRecord.id); + expect(lease.metadata).toMatchObject({ snapshot: "managed-snapshot", snapshotIdentity: { + ...snapshotRecord, provider: "daytona", requestedRef: "managed-snapshot", + } }); + }); + + it("does not resolve snapshot names during ordinary acquisition", async () => { + mockCreate.mockResolvedValue(createMockSandbox()); + await plugin.definition.onEnvironmentAcquireLease!({ ...snapshotProbe, resolveSnapshotIdentity: undefined }); + expect(mockSnapshotGet).not.toHaveBeenCalled(); + expect(mockCreate.mock.calls[0][0].snapshot).toBe("managed-snapshot"); + }); + + it.each([{ id: "mutable-name" }, { organizationId: "" }, { name: "different-name" }, { createdAt: "invalid" }])( + "rejects unverified snapshot identity %j before allocation", async invalid => { + mockSnapshotGet.mockResolvedValue({ ...snapshotRecord, ...invalid }); + await expect(plugin.definition.onEnvironmentAcquireLease!(snapshotProbe)).rejects.toThrow("immutable snapshot identity"); + expect(mockCreate).not.toHaveBeenCalled(); + }, + ); + + it("never allocates after a snapshot identity lookup times out", async () => { + vi.useFakeTimers(); + try { + let resolveLookup!: (value: typeof snapshotRecord) => void; + mockSnapshotGet.mockReturnValue(new Promise(resolve => { resolveLookup = resolve; })); + const result = plugin.definition.onEnvironmentAcquireLease!(snapshotProbe).catch(error => error); + await vi.advanceTimersByTimeAsync(10_001); + expect(await result).toBeInstanceOf(Error); + resolveLookup(snapshotRecord); + await vi.advanceTimersByTimeAsync(1); + expect(mockCreate).not.toHaveBeenCalled(); + } finally { vi.useRealTimers(); } + }); + it("declares environment lifecycle handlers", async () => { expect(await plugin.definition.onHealth?.()).toEqual({ status: "ok", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index b8e71c2ab8f..30e57a67409 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -210,6 +210,8 @@ type DaytonaSnapshotService = { delete?: (snapshot: unknown) => Promise; }; +type SnapshotIdentity = { provider: "daytona"; requestedRef: string; id: string; name: string; organizationId: string; createdAt: string }; + const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json"; // Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against @@ -672,6 +674,7 @@ function leaseMetadata(input: { resumedFromState?: string | null; sandboxState?: string | null; workspaceSentinel?: WorkspaceSentinelResult; + snapshotIdentity?: SnapshotIdentity; }) { return { provider: "daytona", @@ -681,6 +684,7 @@ function leaseMetadata(input: { sandboxState: input.sandboxState ?? input.sandbox.state ?? null, image: input.config.image, snapshot: input.config.snapshot, + ...(input.snapshotIdentity ? { snapshotIdentity: input.snapshotIdentity } : {}), target: input.sandbox.target, timeoutMs: input.config.timeoutMs, reuseLease: input.config.reuseLease, @@ -956,6 +960,7 @@ async function createSandbox( purpose?: string; onCreateAttempt?: (cleanup: PluginEnvironmentCreationCleanup) => void; onCreateFailure?: () => PluginEnvironmentAcquisitionDiagnostic; + onSnapshotIdentity?: (identity: SnapshotIdentity) => void; } = {}, ): Promise { const resourceRequestError = validateRuntimeResourceRequest(config); @@ -963,6 +968,23 @@ async function createSandbox( throw new Error(resourceRequestError); } const client = createDaytonaClient(config); + let createConfig = config; + if ("resolveSnapshotIdentity" in params && params.resolveSnapshotIdentity && config.snapshot && !config.image) { + // This read happens only during explicit qualification. The SDK does not + // bound snapshot.get; a timed-out read must never continue into allocation. + const snapshot = await withLivenessTimeout("snapshot.identity", Math.min(config.timeoutMs || 10_000, 10_000), + () => client.snapshot.get(config.snapshot!)); + if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(snapshot.id ?? "") + || !snapshot.name || !snapshot.organizationId || !snapshot.createdAt + || (config.snapshot !== snapshot.id && config.snapshot !== snapshot.name) + || !Number.isFinite(Date.parse(String(snapshot.createdAt)))) { + throw new Error("Daytona did not return an immutable snapshot identity. Retry the connection probe or select Legacy runner."); + } + const identity: SnapshotIdentity = { provider: "daytona", requestedRef: config.snapshot, + id: snapshot.id, name: snapshot.name, organizationId: snapshot.organizationId, createdAt: new Date(snapshot.createdAt).toISOString() }; + createConfig = { ...config, snapshot: identity.id }; + options.onSnapshotIdentity?.(identity); + } // The SDK can create a sandbox and then throw while waiting for it to start. // Keep an attempt-specific provider name so that failure cannot lose ownership. const attemptId = randomUUID(); @@ -977,7 +999,7 @@ async function createSandbox( }), "paperclip-create-attempt": attemptId }; // The SDK mutates params.labels (for example, code-toolbox-language). // Preserve our immutable ownership snapshot for validation and retry. - const createParams = { ...buildCreateParams(config, { ...labels }), name }; + const createParams = { ...buildCreateParams(createConfig, { ...labels }), name }; const cleanup: PluginEnvironmentCreationCleanup = { providerLeaseId: name, companyId: params.companyId, environmentId: params.environmentId, ...("runId" in params ? { runId: params.runId } : {}), @@ -2491,6 +2513,7 @@ const plugin = definePlugin({ params: PluginEnvironmentAcquireLeaseParams, ): Promise { const config = parseDriverConfig(params.config); + let snapshotIdentity: SnapshotIdentity | undefined; // One budget covers creation, setup, and inline cleanup. The host leaves // 30 seconds beyond this deadline to receive/journal the cleanup record. const budgetMs = config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS; @@ -2534,8 +2557,9 @@ const plugin = definePlugin({ }; const acquire = async (): Promise => { const sandbox = await createSandbox(params, config, { - onCreateAttempt: (attempt) => { cleanup = attempt; }, + onCreateAttempt: (attempt) => { assertActive(); cleanup = attempt; }, onCreateFailure: observeFailure, + onSnapshotIdentity: identity => { snapshotIdentity = identity; }, }); try { assertActive(); @@ -2595,6 +2619,7 @@ const plugin = definePlugin({ remoteCwd, resumedLease: false, workspaceSentinel, + snapshotIdentity, }), }; } catch (error) { diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index cc5d11d154d..cc5c08fc18e 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -635,6 +635,11 @@ export interface PluginEnvironmentRunnerIngressEndpointParams export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams { runId: string; + /** Explicit platform probes may request a provider-owned immutable snapshot + * identity. Supporting providers create by that ID and return snapshotIdentity + * in lease metadata. Older providers may omit it; the host then cannot qualify + * a mutable snapshot name. Ordinary discovery must not allocate a sandbox. */ + resolveSnapshotIdentity?: boolean; workspaceMode?: string; requestedCwd?: string; agentId?: string; diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index 71f2c425557..94d334fddaf 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -50,6 +50,35 @@ describe("probeEnvironment", () => { mockEnvironmentRuntimeService.mockClear(); }); + it("qualifies a named Daytona snapshot only after an ID-pinned lease and owned cleanup", async () => { + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" }, metadata: null } as any; + const identity = { provider: "daytona", requestedRef: "managed-snapshot", id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: "managed-snapshot", organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { snapshot: "managed-snapshot", snapshotIdentity: identity } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.details?.platformQualificationRecorded).toBe(true); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ resolveSnapshotIdentity: true })); + const proof = mockRecordPlatformProbe.mock.calls[1]![0].proof; + expect(proof).toMatchObject({ v: 2, snapshotIdentity: identity }); + environment.metadata = { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } }; + expect(await readSandboxPlatformQualification({} as any, environment, "company")).toMatchObject({ platform: "linux", architecture: "x64", glibcVersion: "glibc 2.39" }); + expect(await readSandboxPlatformQualification({} as any, environment, "foreign-company")).toBeNull(); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledTimes(1); + }); + + it.each(["missing", "wrong-reference", "missing-owner", "cleanup"])("does not qualify a named snapshot after %s", async failure => { + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } } as any; + const identity = { provider: "daytona", requestedRef: failure === "wrong-reference" ? "other" : "managed-snapshot", id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: "managed-snapshot", organizationId: failure === "missing-owner" ? "" : "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { snapshot: "managed-snapshot", ...(failure === "missing" ? {} : { snapshotIdentity: identity }) } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); + if (failure === "cleanup") mockRuntimeReleaseRunLease.mockResolvedValue({ id: "lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "failed" }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.details?.platformQualificationRecorded).toBe(false); + expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1); + }); + it.each(["2.38", "2.39", "musl"])("records actual immutable-image facts for %s and reads them without another lease", async libc => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image, reuseLease: false }, metadata: null } as any; diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index ca6944dc2f2..8e684a33b09 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -322,6 +322,37 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; } + it.each(["paperclip_runner", "claude_local", null])("pins qualified snapshots only for native execution (%s)", async adapterType => { + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const config = { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false, timeoutMs: 1234 }; + await insertSandboxProviderPlugin(pluginId, { environmentDrivers: [{ driverKey: "daytona", kind: "sandbox_provider", displayName: "Daytona", configSchema: { type: "object" } }] }); + await environmentService(db).update(baseEnvironment.id, { driver: "sandbox", config }); + const environment = { ...baseEnvironment, driver: "sandbox", config }; + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); + const snapshotId = "a6b9e7d0-f886-471a-b461-640dd03e8d0f"; + environment.metadata = { paperclipSandboxPlatformProbes: { [companyId]: { + v: 2, companyId, snapshot: config.snapshot, template: null, + baseConfigFingerprint: createHash("sha256").update(stableStringify(config)).digest("hex"), + runtimeConfigFingerprint: createHash("sha256").update(stableStringify(parsed.config)).digest("hex"), + snapshotIdentity: { provider: "daytona", requestedRef: config.snapshot, id: snapshotId, name: config.snapshot, organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }, + } } }; + const worker = createFreshPluginLeaseWorker(pluginId); + vi.mocked(worker.call).mockImplementation(async (_id, method, params: any) => { + if (method !== "environmentAcquireLease") throw new Error(`Unexpected ${method}`); + return { providerLeaseId: "snapshot-lease", metadata: { provider: "daytona", snapshot: params.config.snapshot, remoteCwd: "/workspace" } }; + }); + const service = environmentRuntimeService(db, { pluginWorkerManager: worker }); + const acquired = await service.acquireRunLease({ companyId, environment, issueId: null, heartbeatRunId: runId, + persistedExecutionWorkspace: null, adapterType, ...(adapterType === null ? { resolveSnapshotIdentity: true } : {}) }); + const expectedSnapshot = adapterType === "paperclip_runner" ? snapshotId : config.snapshot; + expect(worker.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ + config: expect.objectContaining({ snapshot: expectedSnapshot }), + ...(adapterType === null ? { resolveSnapshotIdentity: true } : {}), + }), expect.any(Number)); + expect(acquired.lease.metadata?.snapshot).toBe(expectedSnapshot); + }); + function insertReusableExecutionWorkspace(executionWorkspaceId: string, companyId: string, projectId: string) { return db.insert(executionWorkspaces).values({ id: executionWorkspaceId, diff --git a/server/src/__tests__/sandbox-snapshot-qualification.test.ts b/server/src/__tests__/sandbox-snapshot-qualification.test.ts new file mode 100644 index 00000000000..c3e61c1554f --- /dev/null +++ b/server/src/__tests__/sandbox-snapshot-qualification.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { Environment, SandboxEnvironmentConfig } from "@paperclipai/shared"; +import { fingerprintEnvironmentSandboxProviderConfig as fingerprint, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js"; +import { qualifiedSandboxSnapshotId, type SandboxTemplateIdentity } from "../services/sandbox-snapshot-qualification.js"; + +function fixture() { + const config = { provider: "daytona", snapshot: "managed-snapshot", apiKey: "fixture-account" } as SandboxEnvironmentConfig; + const template: SandboxTemplateIdentity = { id: "template", capturedAt: "2026-10-15T00:00:00Z", templateKind: "snapshot", bindingField: "snapshot" }; + const identity = { provider: "daytona", requestedRef: config.snapshot, id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: config.snapshot, organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + const proof = { v: 2, companyId: "company", snapshot: config.snapshot, template, + baseConfigFingerprint: fingerprint(config), runtimeConfigFingerprint: fingerprint(config), snapshotIdentity: identity }; + const environment = { config, metadata: { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } } } as unknown as Environment; + return { config, template, identity, proof, environment }; +} + +describe("qualified snapshot execution binding", () => { + it("pins the measured provider ID without a network lookup", () => { + const f = fixture(); + expect(qualifiedSandboxSnapshotId(f.environment, "company", f.config, f.template)).toBe(f.identity.id); + }); + it.each(["company", "base-config", "account", "template", "snapshot", "image", "provider", "identity"])("rejects changed %s", change => { + const f = fixture(); + let company = "company"; + if (change === "company") company = "foreign-company"; + if (change === "base-config") f.environment.config = { ...f.config, target: "different" }; + if (change === "account") f.config = { ...f.config, apiKey: "different-account" }; + if (change === "template") f.template = { ...f.template!, capturedAt: "2026-10-16T00:00:00Z" }; + if (change === "snapshot") f.config = { ...f.config, snapshot: "new-snapshot" }; + if (change === "image") f.config = { ...f.config, image: "other-image" }; + if (change === "provider") f.config = { ...f.config, provider: "other-provider" }; + if (change === "identity") f.identity.id = "mutable-name"; + expect(qualifiedSandboxSnapshotId(f.environment, company, f.config, f.template)).toBeNull(); + }); +}); diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index 1d6c8ccedc5..c743e0f2950 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -14,6 +14,7 @@ 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 }; @@ -34,7 +35,7 @@ export function parseSandboxPlatformFacts(output: string): PlatformFacts { ...(libc === "glibc" ? { glibcVersion: version?.trim() ?? "unknown" } : {}) }; } async function sandboxBinding(db: Db, environment: Environment, companyId: string) { - const selectedTemplate: { value: { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null } = { value: null }; + const selectedTemplate: { value: SandboxTemplateIdentity } = { value: null }; const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment, { applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true, onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; }, @@ -42,6 +43,10 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin 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), image, template }; @@ -49,9 +54,11 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise { const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]); - if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null; + 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.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint || proof.image !== binding.image + 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" @@ -140,6 +147,7 @@ export async function probeEnvironment( heartbeatRunId: null, persistedExecutionWorkspace: null, adapterType: null, + resolveSnapshotIdentity: true, applyCustomImageTemplate: options.applyCustomImageTemplate === true, }); const metadata = leaseRecord.lease.metadata ?? {}; @@ -149,11 +157,13 @@ export async function probeEnvironment( : null; details = { provider, providerLeaseId: leaseRecord.lease.providerLeaseId, leaseId: leaseRecord.lease.id, leasePolicy: leaseRecord.lease.leasePolicy, metadata, platformQualificationRecorded: false, - platformQualificationDiagnostic: "An immutable OCI image digest is required; this connection probe does not qualify a mutable image or snapshot name." }; - if (isImmutableImage(metadata.image) && !metadata.snapshot) { + 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); - if (binding && metadata.image === binding.image) { + 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 }); @@ -163,10 +173,12 @@ export async function probeEnvironment( if (facts.platform !== "unknown" && facts.architecture !== "unknown" && (facts.platform !== "linux" || facts.libc !== "unknown") && (facts.libc !== "glibc" || /^(?:glibc\s+)?\d+\.\d+(?:\.\d+)?$/.test(facts.glibcVersion ?? ""))) { - qualification = { v: 1, companyId: resolvedCompanyId, ...binding, facts, probedAt: new Date().toISOString() }; + 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 image's OS, architecture or libc could not be identified. Legacy remains the creation default."; - } else details.platformQualificationDiagnostic = "The live lease does not match the selected immutable image configuration. Probe again."; + } 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."; } diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 07bad8dea9c..4950565f2e1 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -43,6 +43,7 @@ import { } from "@paperclipai/adapter-utils/acpx-engine/startup-timing"; import { environmentService } from "./environments.js"; import { instanceSettingsService } from "./instance-settings.js"; +import { qualifiedSandboxSnapshotId, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js"; import { verifyNativeHarnessBackupStamp } from "./native-runtime/native-harness-backup-stamp.js"; import { collectEnvironmentSecretRefs, @@ -454,6 +455,8 @@ export interface EnvironmentDriverAcquireInput { * configured default adapter. */ adapterType: string | null; + /** Resolve an immutable provider snapshot identity for an explicit platform probe. */ + resolveSnapshotIdentity?: boolean; /** * Force applying the active custom-image template even when issueId and * heartbeatRunId are null. Operator-initiated `Test` probes set this so the @@ -1844,14 +1847,19 @@ function createSandboxEnvironmentDriver( async acquireRunLease(input) { const storedParsed = parseEnvironmentDriverConfig(input.environment); + const selectedTemplate: { value: SandboxTemplateIdentity } = { value: null }; const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, { issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, + onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; }, }); if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") { throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`); } + const pinnedSnapshotId = input.adapterType === "paperclip_runner" + ? qualifiedSandboxSnapshotId(input.environment, input.companyId, parsed.config, selectedTemplate.value) : null; + if (pinnedSnapshotId) parsed.config = { ...parsed.config, snapshot: pinnedSnapshotId }; // Check if this provider should be handled by a plugin. if (!isBuiltinSandboxProvider(parsed.config.provider)) { @@ -1882,7 +1890,8 @@ function createSandboxEnvironmentDriver( } const workerConfig = stripSandboxProviderEnvelope(parsed.config); - const storedConfig = storedParsed.config; + const storedConfig = pinnedSnapshotId ? { ...storedParsed.config, snapshot: pinnedSnapshotId } : storedParsed.config; + if (pinnedSnapshotId) delete storedConfig.image; const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig); // Require the reusable-lease capability AND a worker that verifies the // reuse methods. The provider must first opt in through the declaration: @@ -2122,6 +2131,7 @@ function createSandboxEnvironmentDriver( // environment's default adapter image (a pi agent then runs in the // opencode image and the harness binary is missing at exec time). adapterType: input.adapterType ?? undefined, + ...(input.resolveSnapshotIdentity ? { resolveSnapshotIdentity: true } : {}), // Forward a caller deadline so the provider configures a provider-side // expiry at or before it and returns the real provider expiry. ...(requestedExpiresAtParam(input.requestedExpiresAt) !== undefined @@ -4000,6 +4010,7 @@ export function environmentRuntimeService( executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null; /** The agent's adapter type for this run (mixed-harness environments). */ adapterType?: string | null; + resolveSnapshotIdentity?: boolean; /** * Force applying the active custom-image template even for ad-hoc (no * issue/run) invocations. Operator `Test` probes set this so the runtime @@ -4037,6 +4048,7 @@ export function environmentRuntimeService( executionWorkspaceMode: leaseContext.executionWorkspaceMode, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, adapterType: input.adapterType ?? null, + resolveSnapshotIdentity: input.resolveSnapshotIdentity, applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, requestedExpiresAt: input.requestedExpiresAt ?? null, assertCompanyBinding: input.assertCompanyBinding, diff --git a/server/src/services/sandbox-snapshot-qualification.ts b/server/src/services/sandbox-snapshot-qualification.ts new file mode 100644 index 00000000000..56a79a96ddc --- /dev/null +++ b/server/src/services/sandbox-snapshot-qualification.ts @@ -0,0 +1,53 @@ +import type { Environment, SandboxEnvironmentConfig } from "@paperclipai/shared"; +import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; + +export type SandboxTemplateIdentity = { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null; +export type SandboxSnapshotIdentity = { + provider: "daytona"; + requestedRef: string; + id: string; + name: string; + organizationId: string; + createdAt: string; +}; + +export function readSnapshotIdentity(value: unknown, reference: unknown): SandboxSnapshotIdentity | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const identity = value as Record; + if (identity.provider !== "daytona" || identity.requestedRef !== reference + || typeof identity.id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(identity.id) + || typeof identity.name !== "string" || !identity.name.trim() + || (reference !== identity.id && reference !== identity.name) + || typeof identity.organizationId !== "string" || !identity.organizationId.trim() + || typeof identity.createdAt !== "string" || !Number.isFinite(Date.parse(identity.createdAt))) return null; + return identity as SandboxSnapshotIdentity; +} + +export function isQualifiableDaytonaSnapshot( + input: SandboxEnvironmentConfig, template: SandboxTemplateIdentity, +): input is SandboxEnvironmentConfig & Record & { snapshot: string } { + const config = input as unknown as Record; + return config.provider === "daytona" && typeof config.snapshot === "string" && !!config.snapshot.trim() + && !config.image && !config.template && !config.templateRef + && (!template || (template.templateKind === "snapshot" && template.bindingField === "snapshot")); +} + +/** Discovery reads only saved, company-scoped proof. Execution pins the same + * provider ID rather than resolving the mutable name again. A new config or + * captured template needs a new probe; deleting the pinned snapshot fails at + * the provider instead of silently selecting its replacement. */ +export function qualifiedSandboxSnapshotId( + environment: Environment, companyId: string, config: SandboxEnvironmentConfig, template: SandboxTemplateIdentity, +): string | null { + if (!isQualifiableDaytonaSnapshot(config, template)) return null; + const proofs = environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; + if (!proofs || typeof proofs !== "object" || Array.isArray(proofs)) return null; + const proof = (proofs as Record)[companyId]; + if (!proof || typeof proof !== "object" || Array.isArray(proof)) return null; + const value = proof as Record; + if (value.v !== 2 || value.companyId !== companyId + || value.baseConfigFingerprint !== fingerprintEnvironmentSandboxProviderConfig(environment.config as SandboxEnvironmentConfig) + || value.runtimeConfigFingerprint !== fingerprintEnvironmentSandboxProviderConfig(config) + || value.snapshot !== config.snapshot || JSON.stringify(value.template) !== JSON.stringify(template)) return null; + return readSnapshotIdentity(value.snapshotIdentity, config.snapshot)?.id ?? null; +} diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index cc5ecbc1637..667a98a3046 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -1974,10 +1974,10 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( return { root, queryClient }; } - it.each(["paperclip", "legacy"] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration", async measured => { + it.each([["paperclip", "image"], ["legacy", "image"], ["paperclip", "snapshot"], ["legacy", "snapshot"]] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration from %s", async (measured, source) => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { - provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, + provider: "daytona", ...(source === "snapshot" ? { snapshot: "managed-snapshot" } : { image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }), } }]); const discovery = (runner: "paperclip" | "legacy") => [ setupAdapterInfo({ type: "claude_local", loaded: true, disabled: false, defaultRunner: runner, supportedRunners: [runner] }), diff --git a/ui/src/lib/qualify-claude-setup.test.ts b/ui/src/lib/qualify-claude-setup.test.ts index 7d4f467acc8..9f6a22276fc 100644 --- a/ui/src/lib/qualify-claude-setup.test.ts +++ b/ui/src/lib/qualify-claude-setup.test.ts @@ -19,6 +19,12 @@ function setup() { } describe("Claude explicit setup qualification", () => { + it("qualifies a named Daytona snapshot through the normal Connect action", async () => { + const s = setup(); + const input: Parameters[0] = { ...s, environment: { driver: "sandbox", config: { provider: "daytona", snapshot: "managed-snapshot" } } }; + expect((await qualifyClaudeSetup(input))?.runner).toBe("paperclip"); + expect(s.probeEnvironment).toHaveBeenCalledTimes(1); + }); it.each(["paperclip", "legacy"] as const)("uses fresh discovery and honors measured %s selection", async runner => { const s = setup(); s.readAdapters.mockResolvedValueOnce(discovery(runner)); diff --git a/ui/src/lib/qualify-claude-setup.ts b/ui/src/lib/qualify-claude-setup.ts index cfa6a2c8731..97a8e4760fe 100644 --- a/ui/src/lib/qualify-claude-setup.ts +++ b/ui/src/lib/qualify-claude-setup.ts @@ -8,10 +8,11 @@ export function needsClaudeSetupQualification(input: { environment?: Pick | null; }): boolean { const config = input.environment?.config; + const immutableImage = typeof config?.image === "string" && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(config.image) && !config.snapshot; + const daytonaSnapshot = config?.provider === "daytona" && typeof config.snapshot === "string" && !!config.snapshot.trim() && !config.image; return !input.savedAgentId && input.adapterType === "claude_local" && input.runner === "auto" - && input.environment?.driver === "sandbox" && typeof config?.image === "string" - && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(config.image) - && !config.snapshot && !config.template && !config.templateRef; + && input.environment?.driver === "sandbox" && (immutableImage || daytonaSnapshot) + && !config?.template && !config?.templateRef; } export async function qualifyClaudeSetup(input: Parameters[0] & { diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 4679b870724..23446481dd5 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -10,11 +10,12 @@ import { describe("New agent setup", () => { it.each([ - ["paperclip", "Connect"], ["legacy", "Connect"], - ["paperclip", "Finish setup"], ["legacy", "Finish setup"], - ] as const)("qualifies new managed Claude and saves measured %s defaults through %s", async (measured, step) => { + ["paperclip", "Connect", "image"], ["legacy", "Connect", "image"], + ["paperclip", "Finish setup", "image"], ["legacy", "Finish setup", "image"], + ["paperclip", "Connect", "snapshot"], ["paperclip", "Finish setup", "snapshot"], + ] as const)("qualifies new managed Claude and saves measured %s defaults through %s from %s", async (measured, step, source) => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; - envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", image } }]); + envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", ...(source === "snapshot" ? { snapshot: "managed-snapshot" } : { image }) } }]); settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true, enableNativeRunner: false }); api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 });