mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Qualify Claude defaults against pinned Daytona snapshot identities
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2e107b341f
commit
7bd7699c9c
18 files changed
+325
-31
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -210,6 +210,8 @@ type DaytonaSnapshotService = {
|
||||
delete?: (snapshot: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
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<Sandbox> {
|
||||
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<PluginEnvironmentLease> {
|
||||
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<PluginEnvironmentLease> => {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>), 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<PlatformFacts | null> {
|
||||
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.";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> & { snapshot: string } {
|
||||
const config = input as unknown as Record<string, unknown>;
|
||||
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<string, unknown>)[companyId];
|
||||
if (!proof || typeof proof !== "object" || Array.isArray(proof)) return null;
|
||||
const value = proof as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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] }),
|
||||
|
||||
@@ -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<typeof qualifyClaudeSetup>[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));
|
||||
|
||||
@@ -8,10 +8,11 @@ export function needsClaudeSetupQualification(input: {
|
||||
environment?: Pick<Environment, "driver" | "config"> | 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<typeof needsClaudeSetupQualification>[0] & {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user