mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
Use the Runner execution name throughout the guide, capability comments, diagnostics, and test descriptions. Keep the environment-driver RPC contract and project preparation flow intact. Validation: SDK build, server typecheck, and 22 focused host/worker tests passed. Co-Authored-By: Paperclip <noreply@paperclip.ing>
4498 lines
199 KiB
TypeScript
4498 lines
199 KiB
TypeScript
import { executeEnvironmentTask } from "./environment-task-runtime.js";
|
|
import type { PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
|
|
import { beginIdleTrackedWork } from "./task-admission.js";
|
|
import { hasStopOnlyCleanup, prepareSandboxStopAndRetain, readStopOnlyCleanup, settleStopOnlyCleanup, stopOnlyCleanupKey } from "./sandbox-stop-and-retain.js";
|
|
import { readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
|
|
import { preserveEnvironmentSyncOutErrorDiagnostic } from "./environment-sync-out-error.js";
|
|
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
|
import { hasNativeWorkspaceExportResume, releaseCompletedNativeWorkspaceExportRetention } from "./native-runtime/native-workspace-export-resume.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { and, eq, inArray, ne, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { companySecrets, companySecretVersions, environmentLeases, heartbeatRuns } from "@paperclipai/db";
|
|
import type {
|
|
Environment,
|
|
EnvironmentLease,
|
|
EnvironmentLeaseStatus,
|
|
ExecutionWorkspace,
|
|
InstanceExperimentalSettings,
|
|
IssueExecutionWorkspaceSettings,
|
|
PluginEnvironmentConfig,
|
|
SandboxEnvironmentConfig,
|
|
SandboxProviderCapabilities,
|
|
} from "@paperclipai/shared";
|
|
import { resolveDeclaredSandboxCapabilities } from "@paperclipai/shared";
|
|
import type { EffectiveExecutionCapabilities } from "@paperclipai/adapter-utils/execution-target";
|
|
import type { RunnerIngressEndpoint } from "@paperclipai/adapter-utils/runner-connectivity";
|
|
import type {
|
|
CommandManagedDuplexChannel,
|
|
} from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import type {
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentExecuteResult,
|
|
PluginEnvironmentLease,
|
|
PluginEnvironmentRealizeWorkspaceResult,
|
|
PluginEnvironmentSyncResult,
|
|
PluginSyncOperation,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
|
import {
|
|
getActiveStepContext,
|
|
runWithRuntimeParent,
|
|
type StartupSpanContext,
|
|
} from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
import { environmentService } from "./environments.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { verifyNativeHarnessBackupStamp } from "./native-runtime/native-harness-backup-stamp.js";
|
|
import {
|
|
collectEnvironmentSecretRefs,
|
|
parseEnvironmentDriverConfig,
|
|
resolveEnvironmentDriverConfigForRuntime,
|
|
resolveSandboxCleanupConfigSecrets,
|
|
stripSandboxProviderEnvelope,
|
|
} from "./environment-config.js";
|
|
import {
|
|
createEffectiveRunConfigFingerprints,
|
|
type EffectiveRunConfigFingerprint,
|
|
type EffectiveRunConfigSecretVersionMetadata,
|
|
} from "./effective-run-config-fingerprints.js";
|
|
import {
|
|
acquireSandboxProviderLease,
|
|
destroySandboxProviderLease,
|
|
findReusableSandboxProviderLeaseId,
|
|
getSandboxProvider as getBuiltinSandboxProvider,
|
|
isBuiltinSandboxProvider,
|
|
releaseSandboxProviderLease,
|
|
sandboxConfigFromLeaseMetadata,
|
|
sandboxConfigFromLeaseMetadataLoose,
|
|
} from "./sandbox-provider-runtime.js";
|
|
import { pluginRegistryService } from "./plugin-registry.js";
|
|
import type {
|
|
ExecuteLogSink,
|
|
PluginWorkerManager,
|
|
DuplexChannelHostSession,
|
|
DuplexChannelOpenInput as WorkerManagerDuplexChannelOpenInput,
|
|
} from "./plugin-worker-manager.js";
|
|
import {
|
|
REUSABLE_LEASE_WORKER_METHODS,
|
|
destroyPluginEnvironmentLease,
|
|
executePluginEnvironmentCommand,
|
|
realizePluginEnvironmentWorkspace,
|
|
resolvePluginSandboxProviderDriverByKey,
|
|
resolvePluginSandboxProviderDriverById,
|
|
resolvePluginExecuteRpcTimeoutMs,
|
|
resumePluginEnvironmentLease,
|
|
} from "./plugin-environment-driver.js";
|
|
import { collectSecretRefPaths } from "./json-schema-secret-refs.js";
|
|
import { buildWorkspaceRealizationRecordFromDriverInput } from "./workspace-realization.js";
|
|
import {
|
|
createSandboxOrphanCleanupSpool,
|
|
type DeferredOrphanCleanupRecord,
|
|
type SandboxOrphanCleanupSpool,
|
|
} from "./sandbox-orphan-cleanup-spool.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
// The constant error kind for the durable orphan-cleanup-write-failed log. The
|
|
// log never reads the caught exception, because the exception can carry a
|
|
// credential in its name, code, message, cause, or stack. So the log records
|
|
// this constant and the plain provider identifiers only.
|
|
const SANDBOX_ORPHAN_CLEANUP_WRITE_ERROR_KIND = "sandbox_orphan_cleanup_write_failed";
|
|
|
|
// The fixed non-secret refusal a duplex channel open returns when the lease does
|
|
// not grant the opt-in `duplexCommandStream` capability. The service resolves the
|
|
// exact lease capability snapshot and throws this before it reaches any driver.
|
|
const DUPLEX_CHANNEL_CAPABILITY_DENIED =
|
|
"Sandbox lease does not grant the duplex command stream capability.";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sandbox capability contract — one normalizer for both branches
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const SANDBOX_CAPABILITY_KEYS = [
|
|
"reusableLeases",
|
|
"nativeSyncIn",
|
|
"nativeSyncOut",
|
|
"persistentProcessSessions",
|
|
"independentControlCommands",
|
|
"incrementalSessionOutput",
|
|
"concurrentSyncOperations",
|
|
"duplexCommandStream",
|
|
"runnerWebSocketIngress",
|
|
] as const;
|
|
|
|
export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number];
|
|
|
|
/**
|
|
* Opt-in capability keys. Most capabilities are a worker property: an absent
|
|
* declaration defers to the verified baseline and allows the capability. An
|
|
* opt-in capability is a behavioral guarantee that only some providers make, so
|
|
* an absent declaration denies it. A generic one-shot provider must not get an
|
|
* opt-in capability just because its worker verifies a shared verb such as
|
|
* `environmentExecute`. Only a provider that declares the key `true` (and still
|
|
* verifies the prerequisites and passes narrowing) gets the capability.
|
|
*/
|
|
const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet<SandboxCapabilityKey> = new Set([
|
|
"incrementalSessionOutput",
|
|
"concurrentSyncOperations",
|
|
"duplexCommandStream",
|
|
"runnerWebSocketIngress",
|
|
]);
|
|
|
|
/**
|
|
* Verified prerequisite mapping: the worker methods each capability requires.
|
|
*
|
|
* Each capability maps to a list of requirement groups. A group holds one or
|
|
* more verbs; the group is met when the runtime verified AT LEAST ONE verb in
|
|
* it. A capability verifies only when EVERY group is met.
|
|
*
|
|
* The verbs are the worker method names from the worker discovery list (the
|
|
* plugin worker reports them from `handleInitialize`). A built-in provider maps
|
|
* its own methods to the same verb names through
|
|
* {@link builtinSandboxProviderVerifiedMethods}, so both branches share this
|
|
* mapping and the one normalizer below.
|
|
*
|
|
* The mapping was audited against the worker discovery list and the runtime
|
|
* execution guards:
|
|
* - `nativeSyncIn`/`nativeSyncOut` require the matching sync verb; the native
|
|
* sync guard checks both verbs before it routes a lease to the native hook.
|
|
* - `reusableLeases` requires `environmentResumeLease` (reattach),
|
|
* `environmentReleaseLease` (end-of-run release), and `environmentDestroyLease`
|
|
* (stale-lease teardown). All three run on the reuse path: the runtime resumes
|
|
* or releases the lease, and it destroys the stale lease when a resume fails
|
|
* before it acquires a fresh lease.
|
|
* - `persistentProcessSessions` and `independentControlCommands` require
|
|
* `environmentExecute`; both run commands through it.
|
|
* - `incrementalSessionOutput` requires `environmentExecute` too, because the
|
|
* provider tails the session log through it. The verified verb is necessary
|
|
* but not sufficient: this key is opt-in, so the declaration is the real gate
|
|
* (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}).
|
|
* - `concurrentSyncOperations` requires BOTH sync verbs, because parallel
|
|
* bidirectional transfer runs an inbound and an outbound transfer at the same
|
|
* time. The two verbs are separate required groups, so a provider that
|
|
* verifies only one direction cannot get the capability. The verbs are
|
|
* necessary but not sufficient: this key is opt-in, so the declaration is the
|
|
* real gate (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}).
|
|
* - `duplexCommandStream` requires `duplexChannelOpen`, the worker verb that
|
|
* opens the persistent duplex channel. The verified verb is necessary but not
|
|
* sufficient: this key is opt-in, so the declaration is the real gate. A
|
|
* provider that does not implement the duplex open verb resolves `false` and
|
|
* keeps the file bridge.
|
|
*/
|
|
const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record<SandboxCapabilityKey, readonly (readonly string[])[]> = {
|
|
// Reusable leases require ALL reuse verbs. Each verb is its own required
|
|
// group, so every one must be verified. The list function that publishes
|
|
// provider-level reusable support checks the same verbs; both read from
|
|
// `REUSABLE_LEASE_WORKER_METHODS`, so the runtime guard and the published
|
|
// value cannot drift.
|
|
reusableLeases: REUSABLE_LEASE_WORKER_METHODS.map((method) => [method]),
|
|
nativeSyncIn: [["environmentSyncIn"]],
|
|
nativeSyncOut: [["environmentSyncOut"]],
|
|
persistentProcessSessions: [["environmentExecute"]],
|
|
independentControlCommands: [["environmentExecute"]],
|
|
incrementalSessionOutput: [["environmentExecute"]],
|
|
concurrentSyncOperations: [["environmentSyncIn"], ["environmentSyncOut"]],
|
|
duplexCommandStream: [["duplexChannelOpen"]],
|
|
runnerWebSocketIngress: [["environmentRunnerIngressEndpoint"]],
|
|
};
|
|
|
|
function capabilityIsVerified(
|
|
key: SandboxCapabilityKey,
|
|
verifiedMethods: ReadonlySet<string>,
|
|
): boolean {
|
|
return SANDBOX_CAPABILITY_PREREQUISITE_METHODS[key].every((group) =>
|
|
group.some((verb) => verifiedMethods.has(verb)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Map a built-in sandbox provider's own methods to the worker verb names the
|
|
* prerequisite mapping uses, so the built-in branch and the plug-in branch feed
|
|
* the SAME normalizer. A built-in provider has no native sync hooks, so it never
|
|
* verifies a sync verb. It verifies `environmentExecute` when it implements
|
|
* `execute`, and the reuse verbs only when it declares `supportsReusableLeases`.
|
|
* A built-in reusable provider destroys its own leases in-process, so it
|
|
* verifies `environmentDestroyLease` with the two reuse verbs.
|
|
*/
|
|
export function builtinSandboxProviderVerifiedMethods(
|
|
provider: { supportsReusableLeases?: boolean; execute?: unknown } | null | undefined,
|
|
): string[] {
|
|
if (!provider) return [];
|
|
const methods: string[] = [];
|
|
if (typeof provider.execute === "function") {
|
|
methods.push("environmentExecute");
|
|
}
|
|
if (provider.supportsReusableLeases === true) {
|
|
methods.push(
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
);
|
|
}
|
|
return methods;
|
|
}
|
|
|
|
// `EnvironmentDriverCapabilitySupport` and `ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT`
|
|
// now live in the dependency-leaf module `environment-driver-traits.ts`, next to
|
|
// the other static per-driver traits. Re-export both names here so an existing
|
|
// import of this module keeps resolving.
|
|
export {
|
|
ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT,
|
|
type EnvironmentDriverCapabilitySupport,
|
|
} from "./environment-driver-traits.js";
|
|
import { ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT } from "./environment-driver-traits.js";
|
|
|
|
/**
|
|
* The one general capability classifier. It resolves each of the eight effective
|
|
* capabilities as static support ∩ verified ∩ declared ∩ narrowing, and returns
|
|
* the read-only eight-field snapshot. Every driver reads the same classifier, so
|
|
* the runtime no longer branches on the driver name.
|
|
*
|
|
* - `supportedCapabilities` is the driver's static support definition (see
|
|
* {@link ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT}). A capability not in the set
|
|
* resolves `false` regardless of the other inputs. An absent set applies no
|
|
* static gate, so the classifier behaves as the pure declaration ∩ verified ∩
|
|
* narrowing normalizer.
|
|
* - `verifiedMethods` is the runtime's verified worker verb list (the plug-in
|
|
* worker's `supportedMethods`, or a built-in provider mapped through
|
|
* {@link builtinSandboxProviderVerifiedMethods}). A missing or empty list
|
|
* verifies nothing, so every capability resolves `false` (fail closed).
|
|
* - `declared` is the provider's declaration. An absent flag defers to the
|
|
* verified discovery baseline for a worker-property capability; it never grants
|
|
* an opt-in capability. A present flag can only remove a capability.
|
|
* - `narrowing` is the per-target restriction from the config or lease. An absent
|
|
* key applies no restriction; a `false` value removes the capability.
|
|
*
|
|
* The two default rules stay intact. An absent declaration defers to the
|
|
* verified baseline for a worker-property capability. The three opt-in
|
|
* capabilities deny by default (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}). A
|
|
* declaration never grants a capability the runtime did not verify.
|
|
*/
|
|
export function classifyEnvironmentCapabilities(input: {
|
|
verifiedMethods?: readonly string[] | null;
|
|
declared?: Partial<SandboxProviderCapabilities> | null;
|
|
narrowing?: Partial<Record<SandboxCapabilityKey, boolean>> | null;
|
|
supportedCapabilities?: ReadonlySet<SandboxCapabilityKey> | null;
|
|
}): EffectiveExecutionCapabilities {
|
|
const verifiedMethods = new Set(input.verifiedMethods ?? []);
|
|
const declared = input.declared ?? {};
|
|
const narrowing = input.narrowing ?? {};
|
|
const supportedCapabilities = input.supportedCapabilities ?? null;
|
|
|
|
const resolve = (key: SandboxCapabilityKey): boolean => {
|
|
// The static support definition is the first gate. A capability the driver
|
|
// family cannot support resolves false, whatever the other inputs say. An
|
|
// absent set applies no static gate.
|
|
if (supportedCapabilities && !supportedCapabilities.has(key)) return false;
|
|
const verified = capabilityIsVerified(key, verifiedMethods);
|
|
// An absent declaration defers to the verified baseline for a worker-property
|
|
// capability (true = no extra restriction), but denies an opt-in capability
|
|
// (false). A present declaration can only narrow.
|
|
const declaredDefault = SANDBOX_CAPABILITY_OPT_IN_KEYS.has(key) ? false : true;
|
|
const declaredAllows = declared[key] ?? declaredDefault;
|
|
// An absent narrowing applies no restriction.
|
|
const narrowingAllows = narrowing[key] ?? true;
|
|
return verified && declaredAllows && narrowingAllows;
|
|
};
|
|
|
|
return {
|
|
reusableLeases: resolve("reusableLeases"),
|
|
nativeSyncIn: resolve("nativeSyncIn"),
|
|
nativeSyncOut: resolve("nativeSyncOut"),
|
|
persistentProcessSessions: resolve("persistentProcessSessions"),
|
|
independentControlCommands: resolve("independentControlCommands"),
|
|
incrementalSessionOutput: resolve("incrementalSessionOutput"),
|
|
concurrentSyncOperations: resolve("concurrentSyncOperations"),
|
|
duplexCommandStream: resolve("duplexCommandStream"),
|
|
runnerWebSocketIngress: resolve("runnerWebSocketIngress"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the per-target narrowing for a sandbox lease. Narrowing removes a
|
|
* capability that the provider verified and declared but that this specific
|
|
* lease or config cannot use. Each source is grounded in existing runtime
|
|
* behavior:
|
|
* - `reusableLeases` follows this lease's resolved policy (an ephemeral lease
|
|
* never reuses).
|
|
* - a Kubernetes Job lease disables native sync (mirrors the native sync guard,
|
|
* which falls back for a `job` backend or a `nativeFileSyncUnsupported` lease).
|
|
* - `configResolutionFailed` marks that the runtime could not resolve the
|
|
* provider config. A provider whose config cannot be resolved is untrusted, so
|
|
* the runtime fails closed and narrows `persistentProcessSessions`,
|
|
* `incrementalSessionOutput`, and `duplexCommandStream` to false. An empty
|
|
* config alone does not fail closed; only a resolution error does.
|
|
*/
|
|
export function buildSandboxCapabilityNarrowing(input: {
|
|
leasePolicy?: EnvironmentLease["leasePolicy"] | null;
|
|
leaseMetadata?: Record<string, unknown> | null;
|
|
configResolutionFailed?: boolean;
|
|
}): Partial<Record<SandboxCapabilityKey, boolean>> {
|
|
const narrowing: Partial<Record<SandboxCapabilityKey, boolean>> = {};
|
|
const metadata = input.leaseMetadata ?? {};
|
|
|
|
narrowing.reusableLeases = input.leasePolicy === "reuse_by_environment";
|
|
|
|
if (metadata.backend === "job" || metadata.nativeFileSyncUnsupported === true) {
|
|
narrowing.nativeSyncIn = false;
|
|
narrowing.nativeSyncOut = false;
|
|
}
|
|
|
|
if (input.configResolutionFailed === true) {
|
|
// The runtime could not resolve the provider config, so it fails closed and
|
|
// denies persistent process sessions, incremental session output, and the
|
|
// duplex command stream. The session-output streaming gate reads
|
|
// `incrementalSessionOutput`, so it must narrow with the persistent-session
|
|
// gate to keep the fail-closed behavior. The duplex command stream opens a
|
|
// host-owned bidirectional channel, so an untrusted provider must not keep
|
|
// it either.
|
|
narrowing.persistentProcessSessions = false;
|
|
narrowing.incrementalSessionOutput = false;
|
|
narrowing.duplexCommandStream = false;
|
|
}
|
|
|
|
return narrowing;
|
|
}
|
|
|
|
export function buildEnvironmentLeaseContext(input: {
|
|
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
|
}) {
|
|
return {
|
|
executionWorkspaceId: input.persistedExecutionWorkspace?.id ?? null,
|
|
executionWorkspaceMode: input.persistedExecutionWorkspace?.mode ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The per-run duplex bridge input. The host resolves it for each new run before
|
|
* it selects the sandbox callback bridge transport. When `enableDuplexBridge` is
|
|
* false the host keeps the file bridge with no manifest change and no redeploy.
|
|
* The transport selection reads this input in a later phase; this phase only
|
|
* delivers the setting and the per-run read.
|
|
*/
|
|
export interface ResolvedSandboxDuplexBridgeInput {
|
|
/** True only when the instance opts in to the sandbox duplex command stream. */
|
|
enableDuplexBridge: boolean;
|
|
}
|
|
|
|
/**
|
|
* Map the experimental instance setting `enableSandboxDuplexBridge` into the
|
|
* per-run duplex bridge input. The setting is the kill switch. It defaults off,
|
|
* so an absent or false setting keeps the file bridge.
|
|
*/
|
|
export function resolveSandboxDuplexBridgeInput(
|
|
experimental: Pick<InstanceExperimentalSettings, "enableSandboxDuplexBridge">,
|
|
): ResolvedSandboxDuplexBridgeInput {
|
|
return { enableDuplexBridge: experimental.enableSandboxDuplexBridge === true };
|
|
}
|
|
|
|
function stripSecretRefValuesFromPluginLeaseMetadata(input: {
|
|
metadata: Record<string, unknown> | null | undefined;
|
|
schema: Record<string, unknown> | null | undefined;
|
|
}): Record<string, unknown> {
|
|
const sanitized = structuredClone(input.metadata ?? {}) as Record<string, unknown>;
|
|
|
|
for (const path of collectSecretRefPaths(input.schema)) {
|
|
const keys = path.split(".");
|
|
const parents: Array<{ container: Record<string, unknown>; key: string }> = [];
|
|
let cursor: Record<string, unknown> | null = sanitized;
|
|
|
|
for (let index = 0; index < keys.length - 1; index += 1) {
|
|
const key = keys[index]!;
|
|
const next = cursor?.[key];
|
|
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
cursor = null;
|
|
break;
|
|
}
|
|
parents.push({ container: cursor, key });
|
|
cursor = next as Record<string, unknown>;
|
|
}
|
|
|
|
if (!cursor) continue;
|
|
|
|
const leafKey = keys[keys.length - 1]!;
|
|
if (!Object.prototype.hasOwnProperty.call(cursor, leafKey)) continue;
|
|
delete cursor[leafKey];
|
|
|
|
for (let index = parents.length - 1; index >= 0; index -= 1) {
|
|
const { container, key } = parents[index]!;
|
|
const value = container[key];
|
|
if (
|
|
value &&
|
|
typeof value === "object" &&
|
|
!Array.isArray(value) &&
|
|
Object.keys(value as Record<string, unknown>).length === 0
|
|
) {
|
|
delete container[key];
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
export interface EnvironmentDriverAcquireInput {
|
|
companyId: string;
|
|
environment: Environment;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
/**
|
|
* UUID of the owning heartbeat run, or null for ad-hoc invocations
|
|
* (e.g. operator-initiated `Test` probes) that are not tied to a run.
|
|
* Null leases must be released by id via `getDriver(...).releaseRunLease`
|
|
* since `releaseRunLeases(heartbeatRunId)` cannot find them.
|
|
*/
|
|
heartbeatRunId: string | null;
|
|
executionWorkspaceId: string | null;
|
|
executionWorkspaceMode: ExecutionWorkspace["mode"] | null;
|
|
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
|
/**
|
|
* The harness/adapter type for this run (the agent's adapter). Drivers that
|
|
* materialize a per-run sandbox use it to select the runtime image so a single
|
|
* environment can serve mixed harnesses; null falls back to the environment's
|
|
* configured default adapter.
|
|
*/
|
|
adapterType: string | null;
|
|
/**
|
|
* Force applying the active custom-image template even when issueId and
|
|
* heartbeatRunId are null. Operator-initiated `Test` probes set this so the
|
|
* probe uses the operator-prepared custom image for the runtime lease instead
|
|
* of the base image, matching what real agent runs do.
|
|
*/
|
|
applyCustomImageTemplate?: boolean;
|
|
/**
|
|
* The latest time the acquired lease may stay active. A caller with an
|
|
* independent deadline (for example the setup-token login session) sets it,
|
|
* so the driver bounds the persisted lease expiry to this time. The driver
|
|
* records the earlier of this time and the provider expiry. Null or undefined
|
|
* keeps the provider expiry only, so all other callers keep the current
|
|
* behavior.
|
|
*/
|
|
requestedExpiresAt?: Date | null;
|
|
/**
|
|
* Re-check the environment company binding inside the lease insert
|
|
* transaction. The login acquire paths set this so a managed reconciliation
|
|
* that binds the sandbox to another company between the route guard and this
|
|
* acquire cannot let the login run in a foreign-company sandbox. The lease
|
|
* insert then rejects a foreign-company environment with the 403
|
|
* `environment_company_mismatch` and holds no lease. An unbound
|
|
* (instance-global) environment stays open. Other callers keep the current
|
|
* behavior.
|
|
*/
|
|
assertCompanyBinding?: boolean;
|
|
}
|
|
|
|
export interface EnvironmentDriverReleaseInput {
|
|
resourceDisposition?: "stop_and_retain";
|
|
/** Explicit Stop may terminate in-flight setup rather than drain it. */
|
|
cancelActiveWork?: boolean;
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed">;
|
|
}
|
|
|
|
function resolvePluginSandboxRpcTimeoutMs(
|
|
config: Record<string, unknown>,
|
|
defaultTimeoutMs?: number,
|
|
): number | undefined {
|
|
const configuredTimeoutMs = typeof config.timeoutMs === "number" &&
|
|
Number.isFinite(config.timeoutMs) && config.timeoutMs > 0
|
|
? config.timeoutMs
|
|
: defaultTimeoutMs;
|
|
const timeoutCandidates = [
|
|
configuredTimeoutMs,
|
|
typeof config.bridgeRequestTimeoutMs === "number" ? config.bridgeRequestTimeoutMs : undefined,
|
|
]
|
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value));
|
|
|
|
if (timeoutCandidates.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: Math.max(...timeoutCandidates),
|
|
config,
|
|
});
|
|
}
|
|
|
|
export interface EnvironmentDriverLeaseInput {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
failureReason?: string;
|
|
}
|
|
|
|
export interface EnvironmentDriverRealizeWorkspaceInput extends EnvironmentDriverLeaseInput {
|
|
workspace: {
|
|
localPath?: string;
|
|
remotePath?: string;
|
|
mode?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
export interface EnvironmentDriverExecuteInput extends EnvironmentDriverLeaseInput {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutMs?: number;
|
|
/**
|
|
* Run this command outside the lease's persistent session. The run
|
|
* orchestrator sets this on the workspace provision command, which runs before
|
|
* the run opens its trace root. A sandbox provider that opens a persistent
|
|
* session on the first command must run this command one-shot and keep the
|
|
* session closed, so the session first opens on an in-run command whose setup
|
|
* span parents to the run trace. The default keeps the session path.
|
|
*/
|
|
bypassSession?: boolean;
|
|
/**
|
|
* Force the command onto the lease's persistent session even when no run step
|
|
* is active. The ACP process session bridge sets this so the long-lived agent
|
|
* command opens the session and streams its output through the session log
|
|
* stream. `bypassSession: true` still wins, so an explicit bypass is never
|
|
* overridden. The default keeps the context-based session selection.
|
|
*/
|
|
forceSession?: boolean;
|
|
/**
|
|
* Incremental log sink for one execute call. When set, the plugin worker
|
|
* delivers each `stdout` and `stderr` chunk to this sink through the
|
|
* `execute.log` notification while the command runs, before the final result.
|
|
* The runtime forwards it to the plugin worker manager, which routes each
|
|
* chunk to this sink by the host-issued invocation id. A driver that does not
|
|
* stream ignores it and returns only the final result.
|
|
*/
|
|
onLog?: ExecuteLogSink;
|
|
}
|
|
|
|
export interface EnvironmentDriverSyncInput extends EnvironmentDriverLeaseInput {
|
|
operations: PluginSyncOperation[];
|
|
}
|
|
|
|
export interface EnvironmentDriverOpenDuplexChannelInput extends EnvironmentDriverLeaseInput {
|
|
/**
|
|
* The command argument vector the sandbox runs as the duplex channel child
|
|
* process. Element 0 is the program. The worker runs the vector with no
|
|
* shell, so a shell metacharacter in an element cannot inject a command.
|
|
*/
|
|
command: readonly string[];
|
|
}
|
|
|
|
export interface EnvironmentDriverRunnerIngressInput
|
|
extends EnvironmentDriverLeaseInput {
|
|
port: number;
|
|
path: string;
|
|
}
|
|
|
|
export interface EnvironmentRuntimeDriver {
|
|
readonly driver: string;
|
|
acquireRunLease(input: EnvironmentDriverAcquireInput): Promise<EnvironmentLease>;
|
|
releaseRunLease(input: EnvironmentDriverReleaseInput): Promise<EnvironmentLease | null>;
|
|
resumeRunLease?(input: EnvironmentDriverLeaseInput): Promise<PluginEnvironmentLease | EnvironmentLease | null>;
|
|
destroyRunLease?(input: EnvironmentDriverLeaseInput): Promise<EnvironmentLease | null>;
|
|
realizeWorkspace?(input: EnvironmentDriverRealizeWorkspaceInput): Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
|
execute?(input: EnvironmentDriverExecuteInput): Promise<PluginEnvironmentExecuteResult>;
|
|
/**
|
|
* Optional native inbound/outbound file transfer, delegated to the plugin
|
|
* worker's `environmentSyncIn`/`environmentSyncOut` verbs. Only present for
|
|
* plugin-backed sandbox drivers whose worker advertises both verbs; callers
|
|
* gate on {@link EnvironmentRuntimeDriver.supportsSync}.
|
|
*/
|
|
syncIn?(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult>;
|
|
syncOut?(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult>;
|
|
/**
|
|
* Optional persistent duplex channel. Present only for a plugin-backed sandbox
|
|
* driver whose lease grants the `duplexCommandStream` capability. The driver
|
|
* opens the host-owned duplex route on the plugin worker and adapts it to the
|
|
* cross-layer {@link CommandManagedDuplexChannel}. Other drivers omit it.
|
|
*
|
|
* HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback.
|
|
*/
|
|
openDuplexChannel?(
|
|
input: EnvironmentDriverOpenDuplexChannelInput,
|
|
): Promise<CommandManagedDuplexChannel>;
|
|
getRunnerIngressEndpoint?(
|
|
input: EnvironmentDriverRunnerIngressInput,
|
|
): Promise<RunnerIngressEndpoint>;
|
|
/** True when the lease's plugin worker advertises both sync verbs. */
|
|
supportsSync?(input: EnvironmentDriverLeaseInput): boolean;
|
|
/**
|
|
* Resolve the effective eight-field capability snapshot for this driver and
|
|
* lease. Every driver implements this general method, so a caller never
|
|
* needs to branch on the driver name to read a capability.
|
|
*
|
|
* - `local` and `ssh` resolve every capability `false`: their static support
|
|
* definition names none of the eight capabilities (see
|
|
* {@link ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT}).
|
|
* - `sandbox` resolves through the general classifier with its static
|
|
* support definition, the per-lease declaration and verified worker
|
|
* methods, and the per-lease narrowing.
|
|
* - `plugin` resolves through the general classifier with its static
|
|
* support definition, the live plugin worker method list, exact-plugin
|
|
* pinning for the plugin that acquired the lease, and per-lease
|
|
* narrowing.
|
|
*
|
|
* The method always returns a full snapshot, never `null`. A driver that
|
|
* cannot verify a prerequisite (a missing worker, a stale plugin pin, an
|
|
* unresolvable config) fails closed and resolves the affected fields
|
|
* `false`; it does not throw for that case. A caller that needs to tell
|
|
* "this driver has no capability model" apart from "this driver is not
|
|
* registered" reads {@link ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT} directly.
|
|
*/
|
|
resolveCapabilities(input: EnvironmentDriverLeaseInput): Promise<EffectiveExecutionCapabilities>;
|
|
/**
|
|
* Retry the provider teardown for an orphan sandbox that an earlier acquire
|
|
* provisioned but could not tear down. The pending-cleanup lease row carries
|
|
* the provider, the provider lease id, and the immutable config metadata, so
|
|
* the retry resolves the teardown from the row alone. It never reads the
|
|
* current environment provider, so a provider change or an environment delete
|
|
* cannot strand the teardown. `environment` is null when a delete already
|
|
* removed the environment row. The method throws when the teardown fails, so
|
|
* the cleanup sweep keeps the row for a later retry. Any returned provider
|
|
* receipt must be validated and persisted by the caller at lease release.
|
|
*/
|
|
retryPendingSandboxTeardown?(input: { environment: Environment | null; lease: EnvironmentLease }): Promise<unknown>;
|
|
/**
|
|
* Report whether the provider worker can run an orphan teardown now. A plugin
|
|
* sandbox provider worker can be briefly down during its own restart window.
|
|
* A teardown in that window throws, and the cleanup sweep would count that
|
|
* throw against the finite retry cap. So the sweep probes this method before
|
|
* it claims a retry attempt, and skips the lease when the worker is not ready.
|
|
* A later sweep retries after the worker recovers. The method reports `true`
|
|
* for every persistent condition (a built-in provider, a missing plugin, or an
|
|
* absent worker manager), so the teardown still runs, throws, and counts
|
|
* toward the cap. It reports `false` only for the transient worker-down window.
|
|
*/
|
|
isPendingCleanupWorkerReady?(input: { environment: Environment | null; lease: EnvironmentLease }): Promise<boolean>;
|
|
/**
|
|
* Flush the in-process buffer of orphan pending-cleanup records that every
|
|
* synchronous database write could not land. The acquire buffers an orphan
|
|
* here when the database is down after a failed teardown, so no durable row
|
|
* exists yet. The cleanup sweep calls this method each tick. The method
|
|
* re-inserts each buffered record. A record whose write now succeeds leaves the
|
|
* buffer and becomes a normal `pending_cleanup` row that the sweep tears down.
|
|
* A record whose write still fails stays in the buffer for a later flush. The
|
|
* method reports how many records it recovered and how many still wait.
|
|
*/
|
|
flushDeferredOrphanCleanups?(): Promise<{ recovered: number; pending: number }>;
|
|
}
|
|
|
|
/**
|
|
* The acquire provisioned a remote sandbox, the lease insert rejected it, the
|
|
* compensating teardown failed, and every retry of the durable pending-cleanup
|
|
* write also failed. A live sandbox now has no lease row and no cleanup record,
|
|
* so no sweep can find it. The acquire throws this error so the failure stays visible
|
|
* and never resolves to a clean rejection. The `cause` field holds the original
|
|
* lease insert rejection. The `cleanupWriteError` field holds the write failure.
|
|
*/
|
|
export class SandboxOrphanCleanupWriteError extends Error {
|
|
readonly provider: string;
|
|
readonly providerLeaseId: string | null;
|
|
readonly cleanupWriteError: unknown;
|
|
|
|
constructor(input: {
|
|
provider: string;
|
|
providerLeaseId: string | null;
|
|
cause: unknown;
|
|
cleanupWriteError: unknown;
|
|
}) {
|
|
super(
|
|
`Sandbox provider "${input.provider}" leaked lease ` +
|
|
`"${input.providerLeaseId ?? "unknown"}": the acquire could not tear it down ` +
|
|
"and could not record a durable pending-cleanup row. The live sandbox has no " +
|
|
"durable cleanup state and needs a manual teardown.",
|
|
{ cause: input.cause },
|
|
);
|
|
this.name = "SandboxOrphanCleanupWriteError";
|
|
this.provider = input.provider;
|
|
this.providerLeaseId = input.providerLeaseId;
|
|
this.cleanupWriteError = input.cleanupWriteError;
|
|
}
|
|
}
|
|
|
|
/** A reusable sandbox could not be resumed, but has not been proven lost. */
|
|
export class ReusableSandboxResumeError extends Error {
|
|
readonly provider: string;
|
|
readonly providerLeaseId: string;
|
|
|
|
constructor(input: {
|
|
provider: string;
|
|
providerLeaseId: string;
|
|
cause?: unknown;
|
|
}) {
|
|
super(
|
|
`Reusable sandbox lease "${input.providerLeaseId}" could not be resumed; ` +
|
|
"the lease was preserved and no replacement was created.",
|
|
input.cause === undefined ? undefined : { cause: input.cause },
|
|
);
|
|
this.name = "ReusableSandboxResumeError";
|
|
this.provider = input.provider;
|
|
this.providerLeaseId = input.providerLeaseId;
|
|
}
|
|
}
|
|
|
|
export class RunnerHarnessBackupUnavailableError extends Error {
|
|
readonly providerLeaseId: string;
|
|
|
|
constructor(providerLeaseId: string) {
|
|
super(
|
|
`runner_harness_backup_unavailable: reusable sandbox "${providerLeaseId}" ` +
|
|
"was confirmed lost, but no complete verified failover backup is available",
|
|
);
|
|
this.name = "RunnerHarnessBackupUnavailableError";
|
|
this.providerLeaseId = providerLeaseId;
|
|
}
|
|
}
|
|
|
|
export interface EnvironmentRuntimeLeaseRecord {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
leaseContext: ReturnType<typeof buildEnvironmentLeaseContext>;
|
|
}
|
|
|
|
/**
|
|
* Host-side decision for the provider resource after a run. This is kept
|
|
* separate from heartbeat status: a failed turn can still leave a reusable
|
|
* sandbox resumable, while a disposable successful turn must destroy it.
|
|
* An omitted disposition preserves the legacy adapter behavior.
|
|
*/
|
|
export type ProviderResourceDisposition =
|
|
| "keep_running"
|
|
| "stop_and_retain"
|
|
| "destroy";
|
|
|
|
const DEFAULT_PLUGIN_SANDBOX_WORKER_READY_TIMEOUT_MS = 5_000;
|
|
const DEFAULT_PLUGIN_SANDBOX_WORKER_READY_POLL_MS = 100;
|
|
|
|
// The durable pending-cleanup write is the only automated way to find a leaked
|
|
// sandbox. A single insert can lose to a transient database fault (a dropped
|
|
// connection, a lock timeout, a serialization conflict). So the acquire retries
|
|
// the write a few times with a short backoff before it gives up. A later
|
|
// attempt can still land the durable row that a sweep finds.
|
|
const DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_ATTEMPTS = 3;
|
|
const DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_BACKOFF_MS = 100;
|
|
|
|
// A prolonged database outage after a failed teardown can buffer many orphan
|
|
// records in-process. The bound stops that buffer from growing without limit. A
|
|
// full buffer keeps the error log as the last durable handle for a new orphan.
|
|
const DEFAULT_DEFERRED_ORPHAN_CLEANUP_BUFFER_LIMIT = 256;
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function transientSandboxResumeFailure(error: unknown): boolean {
|
|
const candidate = error && typeof error === "object"
|
|
? error as Record<string, unknown>
|
|
: {};
|
|
const status = typeof candidate.status === "number"
|
|
? candidate.status
|
|
: typeof candidate.statusCode === "number"
|
|
? candidate.statusCode
|
|
: null;
|
|
if (status === 429 || (status !== null && status >= 500)) return true;
|
|
const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : "";
|
|
if (["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ENETUNREACH"].includes(code)) {
|
|
return true;
|
|
}
|
|
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
return /\b(timeout|timed out|rate limit|temporar|network|connection reset|service unavailable)\b/.test(message);
|
|
}
|
|
|
|
function getLeaseDriverKey(
|
|
lease: Pick<EnvironmentLease, "metadata">,
|
|
environment: Pick<Environment, "driver"> | null,
|
|
): string {
|
|
const leaseDriver = typeof lease.metadata?.driver === "string" ? lease.metadata.driver : null;
|
|
// An orphan `pending_cleanup` row whose environment a delete removed keeps its
|
|
// driver in the metadata, so the sweep still finds the sandbox driver. Fall
|
|
// back to "sandbox" because only sandbox leases record orphan cleanup.
|
|
return leaseDriver ?? environment?.driver ?? "sandbox";
|
|
}
|
|
|
|
function toEnvironmentLeaseSnapshot(row: typeof environmentLeases.$inferSelect): EnvironmentLease {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
environmentId: row.environmentId,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
issueId: row.issueId ?? null,
|
|
heartbeatRunId: row.heartbeatRunId ?? null,
|
|
status: row.status as EnvironmentLease["status"],
|
|
leasePolicy: row.leasePolicy as EnvironmentLease["leasePolicy"],
|
|
provider: row.provider ?? null,
|
|
providerLeaseId: row.providerLeaseId ?? null,
|
|
acquiredAt: row.acquiredAt,
|
|
lastUsedAt: row.lastUsedAt,
|
|
expiresAt: row.expiresAt ?? null,
|
|
releasedAt: row.releasedAt ?? null,
|
|
failureReason: row.failureReason ?? null,
|
|
cleanupStatus: row.cleanupStatus as EnvironmentLease["cleanupStatus"],
|
|
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
}
|
|
if (isRecord(value)) {
|
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value) ?? "null";
|
|
}
|
|
|
|
function reusableRuntimeFingerprint(input: {
|
|
provider: string;
|
|
adapterType: string | null;
|
|
config: Record<string, unknown>;
|
|
}): string {
|
|
return createHash("sha256")
|
|
.update(stableStringify(input))
|
|
.digest("hex");
|
|
}
|
|
|
|
function serializeLeaseFingerprint(
|
|
fingerprint: EffectiveRunConfigFingerprint | null | undefined,
|
|
): Record<string, unknown> | null {
|
|
if (!fingerprint) return null;
|
|
return {
|
|
version: fingerprint.version,
|
|
category: fingerprint.category,
|
|
algorithm: fingerprint.algorithm,
|
|
fingerprint: fingerprint.fingerprint,
|
|
};
|
|
}
|
|
|
|
function readLeaseFingerprint(value: unknown): string | null {
|
|
return isRecord(value) ? readString(value.fingerprint) : null;
|
|
}
|
|
|
|
async function buildEnvironmentSecretMetadataForLeaseFingerprint(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
environment: Environment;
|
|
}): Promise<EffectiveRunConfigSecretVersionMetadata[]> {
|
|
const refs = await collectEnvironmentSecretRefs({
|
|
db: input.db,
|
|
environment: input.environment,
|
|
});
|
|
if (refs.length === 0) return [];
|
|
|
|
const secretIds = [...new Set(refs.map((ref) => ref.secretId))];
|
|
const secretRows = await input.db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(inArray(companySecrets.id, secretIds));
|
|
const secretsById = new Map(
|
|
secretRows
|
|
.filter((secret) => secret.companyId === input.companyId)
|
|
.map((secret) => [secret.id, secret]),
|
|
);
|
|
|
|
const versionRequests = refs.flatMap((ref) => {
|
|
const secret = secretsById.get(ref.secretId);
|
|
if (!secret) return [];
|
|
const resolvedVersion = ref.versionSelector === "latest" || ref.versionSelector === undefined
|
|
? secret.latestVersion
|
|
: ref.versionSelector;
|
|
return typeof resolvedVersion === "number"
|
|
? [{ secretId: secret.id, version: resolvedVersion }]
|
|
: [];
|
|
});
|
|
const versionSecretIds = [...new Set(versionRequests.map((request) => request.secretId))];
|
|
const versions = [...new Set(versionRequests.map((request) => request.version))];
|
|
const versionRows = versionSecretIds.length > 0 && versions.length > 0
|
|
? await input.db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
and(
|
|
inArray(companySecretVersions.secretId, versionSecretIds),
|
|
inArray(companySecretVersions.version, versions),
|
|
),
|
|
)
|
|
: [];
|
|
const versionsBySecretAndNumber = new Map(
|
|
versionRows.map((row) => [`${row.secretId}:${row.version}`, row]),
|
|
);
|
|
|
|
const metadata: EffectiveRunConfigSecretVersionMetadata[] = [];
|
|
for (const ref of refs) {
|
|
const secret = secretsById.get(ref.secretId);
|
|
if (!secret) {
|
|
metadata.push({
|
|
configPath: ref.configPath,
|
|
envKey: null,
|
|
secretId: ref.secretId,
|
|
version: typeof ref.versionSelector === "number" ? ref.versionSelector : "unresolved",
|
|
outcome: "failure",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const resolvedVersion = ref.versionSelector === "latest" || ref.versionSelector === undefined
|
|
? secret.latestVersion
|
|
: ref.versionSelector;
|
|
const versionRow = typeof resolvedVersion === "number"
|
|
? versionsBySecretAndNumber.get(`${secret.id}:${resolvedVersion}`) ?? null
|
|
: null;
|
|
|
|
metadata.push({
|
|
configPath: ref.configPath,
|
|
envKey: null,
|
|
secretId: secret.id,
|
|
version: resolvedVersion,
|
|
provider: secret.provider,
|
|
providerVersionRef: versionRow?.providerVersionRef ?? null,
|
|
outcome: versionRow ? "success" : "failure",
|
|
});
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
async function buildReusableSandboxLeaseFingerprint(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
environment: Environment;
|
|
executionWorkspaceId: string | null;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
adapterType: string | null;
|
|
provider: string;
|
|
providerConfig: Record<string, unknown>;
|
|
providerPlugin?: {
|
|
id: string;
|
|
pluginKey: string;
|
|
packageName: string;
|
|
version: string;
|
|
} | null;
|
|
}): Promise<EffectiveRunConfigFingerprint> {
|
|
const secretMetadata = await buildEnvironmentSecretMetadataForLeaseFingerprint({
|
|
db: input.db,
|
|
companyId: input.companyId,
|
|
environment: input.environment,
|
|
});
|
|
return createEffectiveRunConfigFingerprints({
|
|
lease: {
|
|
companyId: input.companyId,
|
|
environment: {
|
|
id: input.environment.id,
|
|
driver: input.environment.driver,
|
|
},
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
...(input.executionWorkspaceId ? {} : { projectlessIssueId: input.issueId }),
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: input.provider,
|
|
providerPlugin: input.providerPlugin ?? null,
|
|
providerConfig: input.providerConfig,
|
|
secrets: secretMetadata,
|
|
},
|
|
secretManifest: secretMetadata,
|
|
}).leaseFingerprint;
|
|
}
|
|
|
|
function buildReusableSandboxLeaseScope(input: {
|
|
companyId: string;
|
|
environmentId: string;
|
|
executionWorkspaceId: string | null;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
adapterType: string | null;
|
|
provider: string;
|
|
config: Record<string, unknown>;
|
|
leaseFingerprint?: EffectiveRunConfigFingerprint | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}): Record<string, unknown> | null {
|
|
// Chat tasks need not have a project workspace. Their task + agent identity
|
|
// is a stable, narrower reuse boundary; unscoped/ad-hoc probes cannot match it.
|
|
if ((!input.executionWorkspaceId && !input.issueId) || !input.agentId) return null;
|
|
const providerMetadata = input.providerMetadata ?? {};
|
|
const adapterType = input.adapterType ?? null;
|
|
const remoteCwd = readString(providerMetadata.remoteCwd);
|
|
const workspaceSentinel = isRecord(providerMetadata.workspaceSentinel)
|
|
? { ...providerMetadata.workspaceSentinel }
|
|
: null;
|
|
return {
|
|
version: 1,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
...(input.executionWorkspaceId ? {} : { projectlessIssueId: input.issueId }),
|
|
agentId: input.agentId,
|
|
adapterType,
|
|
provider: input.provider,
|
|
runtimeFingerprint: reusableRuntimeFingerprint({
|
|
provider: input.provider,
|
|
adapterType,
|
|
config: input.config,
|
|
}),
|
|
...(input.leaseFingerprint
|
|
? { leaseFingerprint: serializeLeaseFingerprint(input.leaseFingerprint) }
|
|
: {}),
|
|
...(remoteCwd ? { remoteCwd } : {}),
|
|
...(workspaceSentinel ? { workspaceSentinel } : {}),
|
|
};
|
|
}
|
|
|
|
function reusableSandboxLeaseScopeMatches(input: {
|
|
lease: Pick<EnvironmentLease, "metadata">;
|
|
companyId: string;
|
|
environmentId: string;
|
|
executionWorkspaceId: string | null;
|
|
issueId: string | null;
|
|
agentId: string | null;
|
|
adapterType: string | null;
|
|
provider: string;
|
|
config: Record<string, unknown>;
|
|
leaseFingerprint?: EffectiveRunConfigFingerprint | null;
|
|
allowLegacyRuntimeFingerprint?: boolean;
|
|
}): boolean {
|
|
if ((!input.executionWorkspaceId && !input.issueId) || !input.agentId) return false;
|
|
const scope = input.lease.metadata?.reusableSandboxLease;
|
|
if (!isRecord(scope)) return false;
|
|
const adapterType = input.adapterType ?? null;
|
|
const baseScopeMatches =
|
|
scope.companyId === input.companyId &&
|
|
scope.environmentId === input.environmentId &&
|
|
scope.executionWorkspaceId === input.executionWorkspaceId &&
|
|
(input.executionWorkspaceId !== null || scope.projectlessIssueId === input.issueId) &&
|
|
scope.agentId === input.agentId &&
|
|
scope.adapterType === adapterType &&
|
|
scope.provider === input.provider;
|
|
if (!baseScopeMatches) return false;
|
|
|
|
const expectedLeaseFingerprint = input.leaseFingerprint?.fingerprint ?? null;
|
|
if (expectedLeaseFingerprint) {
|
|
const storedLeaseFingerprint = readLeaseFingerprint(scope.leaseFingerprint);
|
|
if (storedLeaseFingerprint) {
|
|
return storedLeaseFingerprint === expectedLeaseFingerprint;
|
|
}
|
|
if (!input.allowLegacyRuntimeFingerprint) return false;
|
|
}
|
|
|
|
return scope.runtimeFingerprint === reusableRuntimeFingerprint({
|
|
provider: input.provider,
|
|
adapterType,
|
|
config: input.config,
|
|
});
|
|
}
|
|
|
|
function reusableLeaseCanBeResumed(input: {
|
|
lease: Pick<EnvironmentLease, "status" | "heartbeatRunId">;
|
|
heartbeatRunId: string | null;
|
|
}): boolean {
|
|
if (input.lease.status === "released" || input.lease.status === "retained") return true;
|
|
return input.lease.status === "active" && input.heartbeatRunId !== null && input.lease.heartbeatRunId === input.heartbeatRunId;
|
|
}
|
|
|
|
function reusableLeaseCanBeCleanedUp(lease: Pick<EnvironmentLease, "status">): boolean {
|
|
return lease.status === "released" || lease.status === "retained";
|
|
}
|
|
|
|
export function findReusableSandboxLeaseId(input: {
|
|
config: SandboxEnvironmentConfig;
|
|
leases: Array<Pick<EnvironmentLease, "providerLeaseId" | "metadata">>;
|
|
}): string | null {
|
|
// Host-only run behavior (for example streamRunLogs) is intentionally not
|
|
// echoed by a sandbox provider in lease metadata and must not invalidate an
|
|
// otherwise identical reusable provider lease.
|
|
return findReusableSandboxProviderLeaseId({
|
|
config: {
|
|
provider: input.config.provider,
|
|
...stripSandboxProviderEnvelope(input.config),
|
|
} as SandboxEnvironmentConfig,
|
|
leases: input.leases,
|
|
});
|
|
}
|
|
|
|
function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
|
const environmentsSvc = environmentService(db);
|
|
|
|
return {
|
|
driver: "local",
|
|
|
|
async acquireRunLease(input) {
|
|
return await environmentsSvc.acquireLease({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
leasePolicy: "ephemeral",
|
|
provider: "local",
|
|
metadata: {
|
|
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
driver: input.environment.driver,
|
|
executionWorkspaceMode: input.executionWorkspaceMode,
|
|
},
|
|
});
|
|
},
|
|
|
|
async releaseRunLease(input) {
|
|
return await environmentsSvc.releaseLease(input.lease.id, input.status);
|
|
},
|
|
|
|
async retryPendingSandboxTeardown({ lease }) {
|
|
// A restart can strand a local bookkeeping lease after its run ends.
|
|
// There is no provider sandbox to destroy; process ownership is checked
|
|
// separately by conversation continuation before another run is admitted.
|
|
// Never treat an unexpected provider resource as a local no-op cleanup.
|
|
if (lease.provider !== "local" || lease.providerLeaseId !== null) {
|
|
throw new Error("Local lease cleanup cannot release a provider resource.");
|
|
}
|
|
},
|
|
|
|
async realizeWorkspace(input) {
|
|
const record = buildWorkspaceRealizationRecordFromDriverInput({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
workspace: input.workspace,
|
|
cwd: input.workspace.localPath ?? input.workspace.remotePath ?? null,
|
|
});
|
|
return {
|
|
cwd: input.workspace.localPath ?? input.workspace.remotePath ?? "/",
|
|
metadata: {
|
|
workspaceRealization: record,
|
|
},
|
|
};
|
|
},
|
|
|
|
async resolveCapabilities() {
|
|
// The local driver runs commands on the host file system with no
|
|
// provider capability model, so its static support definition names
|
|
// none of the eight capabilities. The classifier resolves every field
|
|
// `false` regardless of the other inputs, so this method needs none.
|
|
return classifyEnvironmentCapabilities({
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.local.supportedCapabilities,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
|
const environmentsSvc = environmentService(db);
|
|
|
|
return {
|
|
driver: "ssh",
|
|
|
|
async acquireRunLease(input) {
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
applyCustomImageTemplate: input.applyCustomImageTemplate ?? false,
|
|
});
|
|
if (parsed.driver !== "ssh") {
|
|
throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`);
|
|
}
|
|
|
|
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
|
|
return await environmentsSvc.acquireLease({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
leasePolicy: "ephemeral",
|
|
provider: "ssh",
|
|
providerLeaseId: `ssh://${parsed.config.username}@${parsed.config.host}:${parsed.config.port}${remoteCwd}`,
|
|
metadata: {
|
|
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
driver: input.environment.driver,
|
|
executionWorkspaceMode: input.executionWorkspaceMode,
|
|
host: parsed.config.host,
|
|
port: parsed.config.port,
|
|
username: parsed.config.username,
|
|
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
|
remoteCwd,
|
|
},
|
|
});
|
|
},
|
|
|
|
async releaseRunLease(input) {
|
|
return await environmentsSvc.releaseLease(input.lease.id, input.status);
|
|
},
|
|
|
|
async realizeWorkspace(input) {
|
|
const record = buildWorkspaceRealizationRecordFromDriverInput({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
workspace: input.workspace,
|
|
cwd:
|
|
typeof input.lease.metadata?.remoteCwd === "string" && input.lease.metadata.remoteCwd.trim().length > 0
|
|
? input.lease.metadata.remoteCwd.trim()
|
|
: input.workspace.remotePath ?? input.workspace.localPath ?? null,
|
|
});
|
|
return {
|
|
cwd: record.remote.path ?? record.local.path,
|
|
metadata: {
|
|
workspaceRealization: record,
|
|
},
|
|
};
|
|
},
|
|
|
|
async resolveCapabilities() {
|
|
// The SSH driver runs commands on a remote host through the SSH
|
|
// transport with no provider capability model, so it supports none of
|
|
// the eight capabilities either.
|
|
return classifyEnvironmentCapabilities({
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.ssh.supportedCapabilities,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Adapt the worker manager's duplex host session to the cross-layer channel. The
|
|
* two shapes differ in the exit and stop members: the host session resolves the
|
|
* exit one time from `wait()`, so the channel bridges it to a one-time `onExit`
|
|
* listener; `kill()` maps to `stop()`. The `write`, `onData`, and `close`
|
|
* members map one to one.
|
|
*/
|
|
function adaptDuplexChannelHostSession(
|
|
session: DuplexChannelHostSession,
|
|
): CommandManagedDuplexChannel {
|
|
return {
|
|
write(data: Uint8Array): void {
|
|
session.write(data);
|
|
},
|
|
onData(listener: (chunk: Uint8Array) => void): void {
|
|
session.onData(listener);
|
|
},
|
|
onExit(listener: (exit: { exitCode: number | null; transportClosed?: boolean }) => void): void {
|
|
// `wait()` resolves one time with the exit and never rejects, so a single
|
|
// `then` bridges it to the one-time exit listener. The exit carries
|
|
// `transportClosed`, so the broker tells a real exit from a transport close.
|
|
void session.wait().then((exit) => {
|
|
listener(exit);
|
|
});
|
|
},
|
|
stop(): void {
|
|
session.kill();
|
|
},
|
|
close(): Promise<void> {
|
|
return session.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSandboxEnvironmentDriver(
|
|
db: Db,
|
|
options: {
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
pluginWorkerReadyTimeoutMs?: number;
|
|
pluginWorkerReadyPollMs?: number;
|
|
pendingCleanupWriteAttempts?: number;
|
|
pendingCleanupWriteBackoffMs?: number;
|
|
deferredOrphanCleanupBufferLimit?: number;
|
|
orphanCleanupSpool?: SandboxOrphanCleanupSpool;
|
|
orphanCleanupSpoolDir?: string;
|
|
} = {},
|
|
): EnvironmentRuntimeDriver {
|
|
const pluginWorkerManager = options.pluginWorkerManager;
|
|
const pluginWorkerReadyTimeoutMs = options.pluginWorkerReadyTimeoutMs ?? DEFAULT_PLUGIN_SANDBOX_WORKER_READY_TIMEOUT_MS;
|
|
const pluginWorkerReadyPollMs = options.pluginWorkerReadyPollMs ?? DEFAULT_PLUGIN_SANDBOX_WORKER_READY_POLL_MS;
|
|
// The retry count is at least one attempt, so a zero or negative override
|
|
// never skips the durable write. The backoff is at least zero milliseconds.
|
|
const pendingCleanupWriteAttempts = Math.max(
|
|
1,
|
|
options.pendingCleanupWriteAttempts ?? DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_ATTEMPTS,
|
|
);
|
|
const pendingCleanupWriteBackoffMs = Math.max(
|
|
0,
|
|
options.pendingCleanupWriteBackoffMs ?? DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_BACKOFF_MS,
|
|
);
|
|
// The buffer holds at least one orphan, so a zero or negative override never
|
|
// drops every buffered record.
|
|
const deferredOrphanCleanupBufferLimit = Math.max(
|
|
1,
|
|
options.deferredOrphanCleanupBufferLimit ?? DEFAULT_DEFERRED_ORPHAN_CLEANUP_BUFFER_LIMIT,
|
|
);
|
|
const environmentsSvc = environmentService(db);
|
|
|
|
// A live sandbox whose teardown failed needs a durable `pending_cleanup` row,
|
|
// so a sweep can find and release it. When every synchronous write attempt
|
|
// fails, the database is down but the process still runs. So the driver keeps
|
|
// the orphan record in this in-process buffer, and a later cleanup sweep
|
|
// flushes it back to the database. The buffer closes the common window where
|
|
// the database recovers after the synchronous attempts but while the process
|
|
// still runs. The `pending_cleanup` failure reason matches the synchronous
|
|
// write, so a flushed row and a synchronous row are indistinguishable to the
|
|
// sweep.
|
|
const deferredOrphanCleanups: DeferredOrphanCleanupRecord[] = [];
|
|
// Keep the token through queue splices, failed flushes and buffer overflow.
|
|
// Release only when the database durably owns cleanup of this exact orphan.
|
|
const orphanIdleWork = new Map<string, () => void>();
|
|
const orphanIdleKey = (record: DeferredOrphanCleanupRecord) => JSON.stringify([
|
|
record.companyId, record.provider, record.providerLeaseId ?? record,
|
|
]);
|
|
const trackOrphanIdleWork = (record: DeferredOrphanCleanupRecord) => {
|
|
const key = orphanIdleKey(record);
|
|
if (!orphanIdleWork.has(key)) orphanIdleWork.set(key, beginIdleTrackedWork());
|
|
};
|
|
const DEFERRED_ORPHAN_CLEANUP_FAILURE_REASON = "acquire_rejected_teardown_failed";
|
|
|
|
// The in-process buffer alone loses an orphan on a restart. So the driver also
|
|
// persists each buffered orphan to this durable local spool. The spool keeps
|
|
// the record on the local disk, next to the other durable server state, so a
|
|
// restart still finds it. The first flush after a restart loads the spool back
|
|
// into the buffer, so the sweep re-inserts the durable row. The spool holds no
|
|
// secret value: it persists the same sanitized metadata the database row
|
|
// holds, so a restart-recovered record still authorizes the teardown but never
|
|
// exposes a credential. Only a crash during the outage before the durable
|
|
// spool write lands loses the record; the error log stays the last handle for
|
|
// that narrow case.
|
|
const orphanCleanupSpool =
|
|
options.orphanCleanupSpool ?? createSandboxOrphanCleanupSpool(options.orphanCleanupSpoolDir);
|
|
|
|
// Add one orphan record to the in-process buffer. Report `false` only when the
|
|
// buffer is full, so the caller keeps the error log as the last durable handle.
|
|
const enqueueDeferredOrphanCleanup = (record: DeferredOrphanCleanupRecord): boolean => {
|
|
trackOrphanIdleWork(record);
|
|
// Dedup by the provider lease id, so a repeated failure for the same orphan
|
|
// never buffers it twice. Each acquire mints a unique provider lease id, so
|
|
// two distinct orphans never collide. Skip the dedup for a null lease id,
|
|
// because a null value carries no identity.
|
|
const alreadyBuffered =
|
|
record.providerLeaseId !== null &&
|
|
deferredOrphanCleanups.some((entry) => entry.providerLeaseId === record.providerLeaseId);
|
|
if (alreadyBuffered) return true;
|
|
if (deferredOrphanCleanups.length >= deferredOrphanCleanupBufferLimit) return false;
|
|
deferredOrphanCleanups.push(record);
|
|
return true;
|
|
};
|
|
|
|
// Load the durable spool into the in-process buffer exactly once. The first
|
|
// flush after a restart runs this load, so the records a previous process
|
|
// could not land re-enter the flush path. A later flush sees the resolved
|
|
// promise and never reloads, so a record this process already buffered never
|
|
// doubles. The load runs before the flush splices the buffer, so a
|
|
// restart-recovered record flushes on the same tick.
|
|
let spoolLoadPromise: Promise<void> | null = null;
|
|
const ensureSpoolLoaded = (): Promise<void> => {
|
|
if (!spoolLoadPromise) {
|
|
spoolLoadPromise = (async () => {
|
|
const persisted = await orphanCleanupSpool.load();
|
|
for (const record of persisted) {
|
|
enqueueDeferredOrphanCleanup(record);
|
|
}
|
|
})();
|
|
}
|
|
return spoolLoadPromise;
|
|
};
|
|
|
|
const flushDeferredOrphanCleanups = async (): Promise<{ recovered: number; pending: number }> => {
|
|
// Load the durable spool into the buffer first, so a restart-recovered record
|
|
// flushes on this tick. The load runs once, then a resolved promise returns.
|
|
await ensureSpoolLoaded();
|
|
if (deferredOrphanCleanups.length === 0) {
|
|
return { recovered: 0, pending: 0 };
|
|
}
|
|
// Take the whole batch synchronously before the first await, so a second
|
|
// concurrent flush sees an empty buffer and never re-inserts the same record.
|
|
const batch = deferredOrphanCleanups.splice(0, deferredOrphanCleanups.length);
|
|
let recovered = 0;
|
|
for (const record of batch) {
|
|
try {
|
|
await environmentsSvc.insertPendingCleanupLease({
|
|
companyId: record.companyId,
|
|
environmentId: record.environmentId,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
issueId: record.issueId,
|
|
heartbeatRunId: record.heartbeatRunId,
|
|
provider: record.provider,
|
|
providerLeaseId: record.providerLeaseId,
|
|
metadata: record.metadata,
|
|
failureReason: DEFERRED_ORPHAN_CLEANUP_FAILURE_REASON,
|
|
});
|
|
// The durable row exists now, so the sweep finds and releases the orphan.
|
|
// Drop the spooled copy only after the row lands. A crash between the two
|
|
// leaves the spooled copy for a later flush, which re-inserts a duplicate
|
|
// the idempotent teardown handles. This order never loses the orphan.
|
|
await orphanCleanupSpool.remove(record);
|
|
const idleKey = orphanIdleKey(record);
|
|
orphanIdleWork.get(idleKey)?.();
|
|
orphanIdleWork.delete(idleKey);
|
|
recovered += 1;
|
|
} catch {
|
|
// The database is still down. Re-queue the record for a later flush,
|
|
// unless the buffer filled again. The identifiers carry no secret; the
|
|
// caught write exception never enters the log, because it can hold a
|
|
// credential in its message, code, cause, or stack.
|
|
const requeued = enqueueDeferredOrphanCleanup(record);
|
|
logger.warn(
|
|
{
|
|
errorKind: SANDBOX_ORPHAN_CLEANUP_WRITE_ERROR_KIND,
|
|
provider: record.provider,
|
|
providerLeaseId: record.providerLeaseId,
|
|
companyId: record.companyId,
|
|
environmentId: record.environmentId,
|
|
requeued,
|
|
},
|
|
requeued
|
|
? "deferred sandbox orphan cleanup flush failed; kept the orphan buffered for a later flush"
|
|
: "deferred sandbox orphan cleanup flush failed and the in-process buffer is full; live sandbox needs a manual teardown",
|
|
);
|
|
}
|
|
}
|
|
return { recovered, pending: deferredOrphanCleanups.length };
|
|
};
|
|
|
|
// Build the safe diagnostic fields for an orphan record. The provider
|
|
// identifiers are the only fields any diagnostic log emits, and they carry no
|
|
// secret. A caught write exception never enters a log: it can carry a
|
|
// credential in its name, code, message, cause, or stack.
|
|
const orphanDiagnosticFields = (record: DeferredOrphanCleanupRecord) => ({
|
|
errorKind: SANDBOX_ORPHAN_CLEANUP_WRITE_ERROR_KIND,
|
|
provider: record.provider,
|
|
providerLeaseId: record.providerLeaseId,
|
|
companyId: record.companyId,
|
|
environmentId: record.environmentId,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
issueId: record.issueId,
|
|
heartbeatRunId: record.heartbeatRunId,
|
|
});
|
|
|
|
// Try to write the durable `pending_cleanup` row for an orphan sandbox, with a
|
|
// few retries. Report the new lease id on success, or a null lease id and the
|
|
// last write error on total failure. This function never buffers, never logs
|
|
// at error level, and never throws. The caller decides whether a total failure
|
|
// is fatal, because a later successful teardown removes the orphan and needs
|
|
// no durable row.
|
|
//
|
|
// The row carries the provider lease id and the same resolution metadata a
|
|
// normal lease holds, so a later cleanup sweep finds and releases the orphan.
|
|
// One atomic insert writes the row directly in the terminal `pending_cleanup`
|
|
// state, and the insert skips the company-binding assertion, so it records the
|
|
// orphan even for a foreign-bound environment. A transient database fault (a
|
|
// dropped connection, a lock timeout, a serialization conflict) can reject one
|
|
// insert but clear on the next, so the write retries a few times with a short
|
|
// backoff.
|
|
const tryWriteDurablePendingCleanup = async (
|
|
record: DeferredOrphanCleanupRecord,
|
|
): Promise<{ leaseId: string | null; lastError: unknown }> => {
|
|
const diagnosticFields = orphanDiagnosticFields(record);
|
|
let lastCleanupWriteError: unknown;
|
|
for (let attempt = 1; attempt <= pendingCleanupWriteAttempts; attempt += 1) {
|
|
try {
|
|
const lease = await environmentsSvc.insertPendingCleanupLease({
|
|
companyId: record.companyId,
|
|
environmentId: record.environmentId,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
issueId: record.issueId,
|
|
heartbeatRunId: record.heartbeatRunId,
|
|
provider: record.provider,
|
|
providerLeaseId: record.providerLeaseId,
|
|
metadata: record.metadata,
|
|
failureReason: DEFERRED_ORPHAN_CLEANUP_FAILURE_REASON,
|
|
});
|
|
// The durable row exists now, so a sweep can find and release the orphan.
|
|
return { leaseId: lease.id, lastError: undefined };
|
|
} catch (cleanupWriteError) {
|
|
lastCleanupWriteError = cleanupWriteError;
|
|
if (attempt < pendingCleanupWriteAttempts) {
|
|
// The write failed, but attempts remain. Log the retry at warn level
|
|
// and back off, then try again. A later attempt can still land the
|
|
// durable row.
|
|
logger.warn(
|
|
{ ...diagnosticFields, attempt, maxAttempts: pendingCleanupWriteAttempts },
|
|
"sandbox orphan cleanup write failed; retrying the durable pending-cleanup write",
|
|
);
|
|
await delay(pendingCleanupWriteBackoffMs * attempt);
|
|
}
|
|
}
|
|
}
|
|
return { leaseId: null, lastError: lastCleanupWriteError };
|
|
};
|
|
|
|
// Release the durable `pending_cleanup` row after a successful inline teardown.
|
|
// The teardown removed the orphan sandbox, so the row is no longer needed. The
|
|
// release moves the row to the terminal `expired` state with a `success`
|
|
// cleanup status, the same state the sweep records for a successful teardown.
|
|
//
|
|
// The release is best-effort. If it fails, the row stays `pending_cleanup`, so
|
|
// a later sweep runs the idempotent teardown on the already-gone sandbox and
|
|
// releases the row itself. A failed release never loses or leaks the orphan.
|
|
const releaseCleanedUpOrphanRow = async (
|
|
leaseId: string,
|
|
diagnosticFields: Record<string, unknown>,
|
|
receipt: unknown,
|
|
): Promise<void> => {
|
|
try {
|
|
const lease = await environmentsSvc.getLeaseById(leaseId);
|
|
await environmentsSvc.releaseLease(leaseId, "expired", {
|
|
...(lease ? { remoteExecutionTermination: remoteTerminationReceipt(lease, receipt) } : {}),
|
|
cleanupStatus: "success",
|
|
failureReason: "acquire_rejected_teardown_succeeded",
|
|
});
|
|
} catch {
|
|
// The caught release exception never enters a log: it can carry a
|
|
// credential in its name, code, message, cause, or stack.
|
|
logger.warn(
|
|
diagnosticFields,
|
|
"could not release the pending-cleanup row after a successful orphan teardown; a later sweep reconciles the released row",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Neither the durable write nor the inline teardown worked, so the orphan
|
|
// sandbox is live with no durable row. Persist the orphan to the durable spool
|
|
// and buffer it in-process, so a later cleanup-sweep flush lands the durable
|
|
// row. The spool survives a restart, so the flush recovers the orphan even
|
|
// after a crash. Then log the plain provider identifiers at error level, so an
|
|
// operator keeps a handle to tear the sandbox down by hand. Only a crash before
|
|
// the spool write lands loses the record, so the error log is the last handle
|
|
// for that narrow case. Throw `SandboxOrphanCleanupWriteError`, which the
|
|
// caller propagates. The error keeps the original insert rejection as its
|
|
// `cause`, so the real reason the acquire failed stays visible in the error
|
|
// chain.
|
|
const escalateUnwrittenOrphan = async (
|
|
record: DeferredOrphanCleanupRecord,
|
|
cause: unknown,
|
|
cleanupWriteError: unknown,
|
|
): Promise<never> => {
|
|
trackOrphanIdleWork(record);
|
|
const persisted = await orphanCleanupSpool.append(record);
|
|
const buffered = enqueueDeferredOrphanCleanup(record);
|
|
logger.error(
|
|
{
|
|
...orphanDiagnosticFields(record),
|
|
persisted,
|
|
buffered,
|
|
deferredBufferSize: deferredOrphanCleanups.length,
|
|
},
|
|
persisted
|
|
? "sandbox orphan cleanup write failed; persisted the orphan to the durable spool for a later cleanup-sweep flush"
|
|
: buffered
|
|
? "sandbox orphan cleanup write failed and the durable spool write failed; buffered the orphan in-process only for a later cleanup-sweep flush"
|
|
: "sandbox orphan cleanup write failed and neither the durable spool nor the in-process buffer kept the orphan; live sandbox needs a manual teardown",
|
|
);
|
|
throw new SandboxOrphanCleanupWriteError({
|
|
provider: record.provider,
|
|
providerLeaseId: record.providerLeaseId,
|
|
cause,
|
|
cleanupWriteError,
|
|
});
|
|
};
|
|
|
|
// Resolve an uncertain creation to a verified provider ID, journal it, then
|
|
// authorize deletion. This makes a later missing-ID response conclusive even
|
|
// when the destructive RPC reply or the final lease update was lost.
|
|
const destroyWithCreationObservation = async (input: {
|
|
leaseId: string | null;
|
|
metadata: Record<string, unknown>;
|
|
teardown: (metadata: Record<string, unknown>) => Promise<unknown>;
|
|
}): Promise<unknown> => {
|
|
try {
|
|
return await input.teardown(input.metadata);
|
|
} catch (error) {
|
|
const previous = readEnvironmentCreationCleanupError({ data: {
|
|
schema: "paperclip/environment-creation-cleanup/v1", cleanup: input.metadata.failedCreateCleanup,
|
|
} });
|
|
const observed = readEnvironmentCreationCleanupError(error);
|
|
const scopeKeys = ["providerLeaseId", "companyId", "environmentId", "runId", "attemptId", "accountFingerprint"] as const;
|
|
if (!input.leaseId || !previous || previous.observedProviderLeaseId || !observed?.observedProviderLeaseId ||
|
|
scopeKeys.some((key) => previous[key] !== observed[key]) ||
|
|
Object.keys(previous.labels).length !== Object.keys(observed.labels).length ||
|
|
Object.entries(previous.labels).some(([key, value]) => observed.labels[key] !== value)) throw error;
|
|
const lease = await environmentsSvc.getLeaseById(input.leaseId);
|
|
if (!lease || lease.companyId !== previous.companyId || lease.providerLeaseId !== previous.providerLeaseId ||
|
|
(lease.environmentId !== null && lease.environmentId !== previous.environmentId)) throw error;
|
|
const metadata = { ...lease.metadata, failedCreateCleanup: observed };
|
|
// A failed write must stop here; no destructive call is permitted until
|
|
// the provider identity can survive a host or worker restart.
|
|
if (!await environmentsSvc.updateLeaseMetadata(lease.id, metadata)) throw error;
|
|
return await input.teardown(metadata);
|
|
}
|
|
};
|
|
|
|
// Clean up an orphan after a rejected insert or uncertain provider creation.
|
|
// The acquire provisioned a remote sandbox, then the insert rejected (a
|
|
// foreign-company binding), so no lease row tracks the live sandbox. This
|
|
// handler records the durable `pending_cleanup` row FIRST, before the inline
|
|
// teardown, then tears the sandbox down.
|
|
//
|
|
// The write goes first on purpose. The conditional insert just returned a
|
|
// definitive rejection, so the database is reachable at this instant. An
|
|
// inline teardown can run long, and the database can drop during it. So a
|
|
// write after the teardown can fail and leave the orphan only in memory, which
|
|
// a restart loses. With the write first, the durable row lands while the
|
|
// database is proven up, so a crash after it still leaves a row that a sweep
|
|
// finds and releases.
|
|
//
|
|
// The teardown then runs. On success the orphan is gone, so the handler
|
|
// releases the durable row. On a failed teardown the row stays for the sweep.
|
|
// If the durable write itself failed and the teardown also failed, the handler
|
|
// buffers the orphan and throws `SandboxOrphanCleanupWriteError`. A successful
|
|
// teardown after a failed write leaves no orphan, so it needs no error.
|
|
const cleanUpRejectedOrphanSandbox = async (input: {
|
|
record: DeferredOrphanCleanupRecord;
|
|
cause: unknown;
|
|
canTeardown: boolean;
|
|
teardown: (pendingLeaseId: string | null) => Promise<unknown>;
|
|
}): Promise<boolean> => {
|
|
const durable = await tryWriteDurablePendingCleanup(input.record);
|
|
let teardownFailed = !input.canTeardown;
|
|
let receipt: unknown;
|
|
if (!teardownFailed) {
|
|
try {
|
|
receipt = await input.teardown(durable.leaseId);
|
|
} catch {
|
|
teardownFailed = true;
|
|
}
|
|
}
|
|
if (!teardownFailed) {
|
|
// The teardown removed the orphan, so drop the durable row if we wrote one.
|
|
if (durable.leaseId !== null) {
|
|
await releaseCleanedUpOrphanRow(durable.leaseId, orphanDiagnosticFields(input.record), receipt);
|
|
}
|
|
return true;
|
|
}
|
|
// The teardown failed, so the orphan sandbox is still live.
|
|
if (durable.leaseId !== null) {
|
|
// The durable row already tracks the orphan, so a sweep finds and releases
|
|
// it. The caller rethrows the original insert rejection.
|
|
return false;
|
|
}
|
|
return await escalateUnwrittenOrphan(input.record, input.cause, durable.lastError);
|
|
};
|
|
|
|
// The run-time exec parent context, held per lease id. A plugin sandbox
|
|
// provider can open a persistent session on the first command and delete it
|
|
// on lease release. The session open runs inside `execute`, under the run
|
|
// parent (the same context the `sandbox.exec` span reads). The session delete
|
|
// runs inside the lease-release RPC, which the run orchestrator calls after
|
|
// the run, outside that scope. Without a parent the host mints no
|
|
// `traceparent` and drops the provider `session.teardown` span. So `execute`
|
|
// records the exec parent here, and the release paths replay it around the
|
|
// release RPC. The host still mints and validates the `traceparent` itself;
|
|
// the value only widens which host calls carry a run parent. An entry is
|
|
// removed on release, so the map holds at most one context per live lease.
|
|
const runExecParentByLeaseId = new Map<string, StartupSpanContext>();
|
|
|
|
// Run a lease-release RPC under the lease's recorded exec parent context, and
|
|
// then drop the entry — the lease is gone. Under the parent the host mints a
|
|
// `traceparent`, so a provider `session.teardown` span reaches the span
|
|
// backend in the run trace. With no recorded context (no command ran, or a
|
|
// local target with no trace context) the call runs unwrapped, exactly as
|
|
// before, so the change never fails a release.
|
|
function runLeaseReleaseWithRunParent<T>(leaseId: string, call: () => Promise<T>): Promise<T> {
|
|
const runParent = runExecParentByLeaseId.get(leaseId);
|
|
runExecParentByLeaseId.delete(leaseId);
|
|
return runParent !== undefined ? runWithRuntimeParent(runParent, call) : call();
|
|
}
|
|
|
|
async function resolveSandboxProviderPlugin(input: { provider: string; pluginId?: string }) {
|
|
if (input.pluginId) {
|
|
const pinned = await resolvePluginSandboxProviderDriverById({ db, pluginId: input.pluginId, driverKey: input.provider });
|
|
if (!pinned) return { state: "missing" as const, resolved: null };
|
|
if (pinned.plugin.status !== "ready") return { state: "not_ready" as const, resolved: pinned };
|
|
if (!pluginWorkerManager?.isRunning(pinned.plugin.id)) return { state: "worker_unavailable" as const, resolved: pinned };
|
|
return { state: "running" as const, resolved: pinned };
|
|
}
|
|
const running = await resolvePluginSandboxProviderDriverByKey({
|
|
db,
|
|
driverKey: input.provider,
|
|
workerManager: pluginWorkerManager,
|
|
requireRunning: true,
|
|
});
|
|
if (running) {
|
|
return { state: "running" as const, resolved: running };
|
|
}
|
|
|
|
const installed = await resolvePluginSandboxProviderDriverByKey({
|
|
db,
|
|
driverKey: input.provider,
|
|
workerManager: pluginWorkerManager,
|
|
requireRunning: false,
|
|
});
|
|
if (!installed) {
|
|
return { state: "missing" as const, resolved: null };
|
|
}
|
|
|
|
if (installed.plugin.status !== "ready") {
|
|
return { state: "not_ready" as const, resolved: installed };
|
|
}
|
|
|
|
if (!pluginWorkerManager) {
|
|
return { state: "worker_unavailable" as const, resolved: installed };
|
|
}
|
|
|
|
const deadline = Date.now() + Math.max(0, pluginWorkerReadyTimeoutMs);
|
|
while (Date.now() < deadline) {
|
|
const retried = await resolvePluginSandboxProviderDriverByKey({
|
|
db,
|
|
driverKey: input.provider,
|
|
workerManager: pluginWorkerManager,
|
|
requireRunning: true,
|
|
});
|
|
if (retried) {
|
|
return { state: "running" as const, resolved: retried };
|
|
}
|
|
await delay(Math.max(1, pluginWorkerReadyPollMs));
|
|
}
|
|
|
|
return { state: "worker_unavailable" as const, resolved: installed };
|
|
}
|
|
|
|
async function resolvePluginSandboxRuntimeConfig(input: {
|
|
environment: Pick<Environment, "id" | "driver" | "config">;
|
|
lease: EnvironmentLease;
|
|
provider: string;
|
|
}): Promise<Record<string, unknown>> {
|
|
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
|
if (metadataConfig && metadataConfig.provider === input.provider) {
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
|
id: input.environment.id,
|
|
driver: "sandbox",
|
|
config: sandboxConfigForLeaseMetadata(metadataConfig),
|
|
});
|
|
if (parsed.driver === "sandbox") {
|
|
return dropInternalPluginSandboxConfigKeys(parsed.config as unknown as Record<string, unknown>);
|
|
}
|
|
}
|
|
|
|
if (input.environment.driver === "sandbox") {
|
|
try {
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(
|
|
db,
|
|
input.lease.companyId,
|
|
input.environment,
|
|
);
|
|
if (parsed.driver === "sandbox" && parsed.config.provider === input.provider) {
|
|
return dropInternalPluginSandboxConfigKeys(parsed.config as unknown as Record<string, unknown>);
|
|
}
|
|
} catch {
|
|
// Lease metadata below is intentionally kept sufficient for cleanup
|
|
// after the environment config changes or becomes invalid.
|
|
}
|
|
}
|
|
|
|
return {
|
|
provider: input.provider,
|
|
...dropInternalPluginSandboxConfigKeys(input.lease.metadata),
|
|
};
|
|
}
|
|
|
|
async function cleanupObsoleteReusableSandboxLeases(input: {
|
|
environment: Environment;
|
|
leases: EnvironmentLease[];
|
|
reusableLeases: EnvironmentLease[];
|
|
}) {
|
|
const reusableIds = new Set(input.reusableLeases.map((lease) => lease.id));
|
|
for (const lease of input.leases) {
|
|
if (reusableIds.has(lease.id)) continue;
|
|
if (!reusableLeaseCanBeCleanedUp(lease)) continue;
|
|
await destroyReusableSandboxLease({
|
|
environment: input.environment,
|
|
lease,
|
|
failureReason: "lease_fingerprint_mismatch",
|
|
});
|
|
}
|
|
}
|
|
|
|
async function callPluginEnvironmentSync(
|
|
method: "environmentSyncIn" | "environmentSyncOut",
|
|
input: EnvironmentDriverSyncInput,
|
|
): Promise<PluginEnvironmentSyncResult> {
|
|
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) {
|
|
throw new Error("Sandbox driver does not support native file sync for this lease.");
|
|
}
|
|
const pluginId = readString(input.lease.metadata?.pluginId);
|
|
const providerKey = readString(input.lease.metadata?.provider);
|
|
if (!pluginId || !providerKey) {
|
|
throw new Error("Sandbox lease is missing plugin/provider metadata for native file sync.");
|
|
}
|
|
const config = await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
const sanitizedConfig = stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig);
|
|
return await pluginWorkerManager.call(pluginId, method, {
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: sanitizedConfig,
|
|
lease: {
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: input.lease.metadata ?? undefined,
|
|
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
|
},
|
|
operations: input.operations,
|
|
}, resolvePluginSandboxRpcTimeoutMs(sanitizedConfig)).catch((error: unknown) => {
|
|
throw method === "environmentSyncOut" ? preserveEnvironmentSyncOutErrorDiagnostic(error) : error;
|
|
});
|
|
}
|
|
|
|
return {
|
|
driver: "sandbox",
|
|
|
|
async acquireRunLease(input) {
|
|
const storedParsed = parseEnvironmentDriverConfig(input.environment);
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
applyCustomImageTemplate: input.applyCustomImageTemplate ?? false,
|
|
});
|
|
if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") {
|
|
throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`);
|
|
}
|
|
|
|
// Check if this provider should be handled by a plugin.
|
|
if (!isBuiltinSandboxProvider(parsed.config.provider)) {
|
|
const pluginProvider = await resolveSandboxProviderPlugin({
|
|
provider: parsed.config.provider,
|
|
});
|
|
if (pluginProvider.state === "missing") {
|
|
throw new Error(
|
|
`Sandbox provider "${parsed.config.provider}" is not registered as a built-in provider and no matching plugin is available.`,
|
|
);
|
|
}
|
|
if (pluginProvider.state === "not_ready") {
|
|
throw new Error(
|
|
`Sandbox provider "${parsed.config.provider}" is installed via plugin "${pluginProvider.resolved.plugin.pluginKey}", but that plugin is currently ${pluginProvider.resolved.plugin.status}.`,
|
|
);
|
|
}
|
|
// A missing manager is a wiring failure, even when the plugin worker
|
|
// is healthy elsewhere in this process. Check it before worker state.
|
|
if (!pluginWorkerManager) {
|
|
throw new Error(
|
|
`Sandbox provider "${parsed.config.provider}" is installed, but sandbox plugin workers are unavailable in this server process.`,
|
|
);
|
|
}
|
|
if (pluginProvider.state === "worker_unavailable") {
|
|
throw new Error(
|
|
`Sandbox provider "${parsed.config.provider}" is installed via plugin "${pluginProvider.resolved.plugin.pluginKey}", but its worker is not running.`,
|
|
);
|
|
}
|
|
|
|
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
|
|
const storedConfig = storedParsed.config;
|
|
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:
|
|
// the nested `sandboxCapabilities.reusableLeases` wins over the legacy
|
|
// `supportsReusableLeases` flag. The worker must also verify
|
|
// `environmentResumeLease`, `environmentReleaseLease`, and
|
|
// `environmentDestroyLease` (the reuse prerequisite verbs) before the
|
|
// runtime resumes, releases, or tears down a reusable lease. A provider
|
|
// that declares `reusableLeases` true but whose worker does not verify
|
|
// all three methods fails closed and uses an ephemeral lease, so the
|
|
// runtime never dispatches a resume, a release, or a destroy the worker
|
|
// cannot serve, and it never strands a stale lease it cannot destroy.
|
|
const declaredReusableLeases =
|
|
resolveDeclaredSandboxCapabilities(pluginProvider.resolved.driver).reusableLeases === true;
|
|
const pluginVerifiedMethods = new Set(
|
|
pluginWorkerManager.getWorker(pluginProvider.resolved.plugin.id)?.supportedMethods ?? [],
|
|
);
|
|
const supportsReusableLeases =
|
|
declaredReusableLeases && capabilityIsVerified("reusableLeases", pluginVerifiedMethods);
|
|
const leaseFingerprint =
|
|
supportsReusableLeases &&
|
|
parsed.config.reuseLease &&
|
|
input.heartbeatRunId !== null &&
|
|
(input.executionWorkspaceId !== null || input.issueId !== null) &&
|
|
input.agentId !== null
|
|
? await buildReusableSandboxLeaseFingerprint({
|
|
db,
|
|
companyId: input.companyId,
|
|
environment: input.environment,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: parsed.config.provider,
|
|
providerConfig: providerConfigForLease,
|
|
providerPlugin: {
|
|
id: pluginProvider.resolved.plugin.id,
|
|
pluginKey: pluginProvider.resolved.plugin.pluginKey,
|
|
packageName: pluginProvider.resolved.plugin.packageName,
|
|
version: pluginProvider.resolved.plugin.version,
|
|
},
|
|
})
|
|
: null;
|
|
// Ad-hoc tests (heartbeatRunId === null) must never resume an existing
|
|
// provider lease. If they did, releasing the test lease at the end of
|
|
// the probe would tear down the live heartbeat run that owns it.
|
|
// We also filter out leases whose policy is not reuse_by_environment
|
|
// and whose status is not reusable so non-reusable, cleanup-pending,
|
|
// or terminal rows cannot be matched.
|
|
const reusableCandidateLeases =
|
|
supportsReusableLeases &&
|
|
parsed.config.reuseLease &&
|
|
input.heartbeatRunId !== null &&
|
|
(input.executionWorkspaceId !== null || input.issueId !== null) &&
|
|
input.agentId !== null
|
|
? (await environmentsSvc.listLeases(input.environment.id))
|
|
.filter((lease) =>
|
|
lease.leasePolicy === "reuse_by_environment" &&
|
|
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
|
|
lease.executionWorkspaceId === input.executionWorkspaceId &&
|
|
(input.executionWorkspaceId !== null || lease.issueId === input.issueId) &&
|
|
lease.metadata?.agentId === input.agentId,
|
|
)
|
|
: [];
|
|
const reusableExistingLeases = reusableCandidateLeases.filter((lease) =>
|
|
reusableSandboxLeaseScopeMatches({
|
|
lease,
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: parsed.config.provider,
|
|
config: providerConfigForLease,
|
|
leaseFingerprint,
|
|
allowLegacyRuntimeFingerprint:
|
|
lease.status === "active" &&
|
|
input.heartbeatRunId !== null &&
|
|
lease.heartbeatRunId === input.heartbeatRunId,
|
|
}),
|
|
);
|
|
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
|
|
await cleanupObsoleteReusableSandboxLeases({
|
|
environment: input.environment,
|
|
leases: reusableCandidateLeases,
|
|
reusableLeases: reusableExistingLeases,
|
|
});
|
|
}
|
|
const reusableProviderLeaseId =
|
|
supportsReusableLeases &&
|
|
parsed.config.reuseLease &&
|
|
input.heartbeatRunId !== null &&
|
|
(input.executionWorkspaceId !== null || input.issueId !== null) &&
|
|
input.agentId !== null
|
|
? findReusableSandboxLeaseId({ config: storedConfig, leases: reusableExistingLeases })
|
|
: null;
|
|
const reusableLease = reusableProviderLeaseId
|
|
? reusableExistingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
|
|
: null;
|
|
|
|
let providerLease: PluginEnvironmentLease | null = null;
|
|
let replacementReason:
|
|
| "not_found"
|
|
| "expired"
|
|
| "identity_mismatch"
|
|
| "resume_failed"
|
|
| undefined;
|
|
if (reusableLease?.providerLeaseId) {
|
|
// The `supportsReusableLeases` check above reads a snapshot of the
|
|
// worker methods. The runtime then does asynchronous database work
|
|
// (list, fingerprint, obsolete-lease cleanup) before this dispatch. A
|
|
// worker restart in that window can drop `environmentResumeLease`
|
|
// while the snapshot still marks the method verified. Re-check the
|
|
// live worker here and fail closed when the method is absent. The
|
|
// runtime preserves the recorded lease and never dispatches a resume
|
|
// the live worker cannot serve.
|
|
const workerVerifiesResume = pluginWorkerVerifiesLifecycleMethod(
|
|
pluginProvider.resolved.plugin.id,
|
|
"environmentResumeLease",
|
|
);
|
|
if (!workerVerifiesResume) {
|
|
throw new ReusableSandboxResumeError({
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: reusableLease.providerLeaseId,
|
|
});
|
|
}
|
|
try {
|
|
const resumeDeadline = Date.now() + 60_000;
|
|
const configuredResumeTimeoutMs =
|
|
resolvePluginSandboxRpcTimeoutMs(workerConfig) ?? 60_000;
|
|
let retryDelayMs = 250;
|
|
let resumed: PluginEnvironmentLease;
|
|
while (true) {
|
|
try {
|
|
resumed = await pluginWorkerManager.call(
|
|
pluginProvider.resolved.plugin.id,
|
|
"environmentResumeLease",
|
|
{
|
|
driverKey: parsed.config.provider,
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.issueId,
|
|
config: workerConfig,
|
|
providerLeaseId: reusableLease.providerLeaseId,
|
|
leaseMetadata: reusableLease.metadata ?? undefined,
|
|
},
|
|
Math.min(
|
|
configuredResumeTimeoutMs,
|
|
Math.max(1, resumeDeadline - Date.now()),
|
|
),
|
|
);
|
|
break;
|
|
} catch (error) {
|
|
if (
|
|
!transientSandboxResumeFailure(error) ||
|
|
Date.now() + retryDelayMs * 1.25 >= resumeDeadline
|
|
) throw error;
|
|
const jitteredDelayMs = Math.max(
|
|
1,
|
|
Math.round(retryDelayMs * (0.75 + Math.random() * 0.5)),
|
|
);
|
|
await delay(jitteredDelayMs);
|
|
retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
|
|
}
|
|
}
|
|
providerLease =
|
|
typeof resumed.providerLeaseId === "string" && resumed.providerLeaseId.length > 0
|
|
? resumed
|
|
: null;
|
|
if (!providerLease) {
|
|
const sentinel = isRecord(resumed.metadata?.workspaceSentinel)
|
|
? resumed.metadata.workspaceSentinel
|
|
: null;
|
|
replacementReason = sentinel && sentinel.result !== "matched"
|
|
? "identity_mismatch"
|
|
: resumed.metadata?.expired === true
|
|
? "not_found"
|
|
: "expired";
|
|
}
|
|
} catch (error) {
|
|
throw new ReusableSandboxResumeError({
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: reusableLease.providerLeaseId,
|
|
cause: error,
|
|
});
|
|
}
|
|
if (!providerLease) {
|
|
if (
|
|
input.adapterType === "paperclip_runner" &&
|
|
!verifyNativeHarnessBackupStamp(
|
|
reusableLease.metadata?.nativeHarnessBackup,
|
|
reusableLease.providerLeaseId,
|
|
)
|
|
) {
|
|
throw new RunnerHarnessBackupUnavailableError(
|
|
reusableLease.providerLeaseId,
|
|
);
|
|
}
|
|
// The verified, lease-bound backup authorizes destructive
|
|
// replacement. Keep the existing sandbox intact when validation
|
|
// fails so the only recoverable provider state is not lost.
|
|
await destroyReusableSandboxLease({
|
|
environment: input.environment,
|
|
lease: reusableLease,
|
|
failureReason: replacementReason ?? "resume_failed",
|
|
});
|
|
}
|
|
}
|
|
const acquisitionRunId = input.heartbeatRunId ?? randomUUID();
|
|
const acquiredLease = providerLease ?? await (async () => {
|
|
try {
|
|
return await pluginWorkerManager.call(
|
|
pluginProvider.resolved.plugin.id,
|
|
"environmentAcquireLease",
|
|
{
|
|
driverKey: parsed.config.provider,
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.issueId,
|
|
config: workerConfig,
|
|
// Plugin SDK requires a string; ad-hoc test leases use a fresh
|
|
// UUID so providers that validate or persist the runId still see
|
|
// a well-formed identifier.
|
|
runId: acquisitionRunId,
|
|
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
|
agentId: input.agentId ?? undefined,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
|
|
// The agent's harness for THIS run, so the plugin picks the matching
|
|
// runtime image (per-run adapter, mixed-harness environments).
|
|
// NOTE: environment-runtime.ts has TWO drivers calling
|
|
// environmentAcquireLease; this plugin-sandbox one is the HEARTBEAT
|
|
// path. Omitting adapterType here silently falls back to the
|
|
// 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,
|
|
// 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
|
|
? { requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt) }
|
|
: {}),
|
|
},
|
|
resolvePluginSandboxRpcTimeoutMs(
|
|
workerConfig,
|
|
pluginProvider.resolved.driver.defaultAcquireTimeoutMs,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
const cleanup = readEnvironmentCreationCleanupError(error);
|
|
// The authenticated worker may identify its uncertain allocation,
|
|
// but cannot redirect cleanup to another company, environment, or run.
|
|
if (cleanup && cleanup.companyId === input.companyId &&
|
|
cleanup.environmentId === input.environment.id && cleanup.runId === acquisitionRunId) {
|
|
const cleanupMetadata = {
|
|
...sandboxConfigForLeaseMetadata(storedConfig),
|
|
failedCreateCleanup: cleanup,
|
|
driver: input.environment.driver,
|
|
pluginId: pluginProvider.resolved.plugin.id,
|
|
pluginKey: pluginProvider.resolved.plugin.pluginKey,
|
|
sandboxProviderPlugin: true,
|
|
};
|
|
// Record before retrying the provider. Existing pending-cleanup
|
|
// recovery (including its durable spool) survives controller loss.
|
|
const cleanupConfirmed = await cleanUpRejectedOrphanSandbox({
|
|
record: {
|
|
companyId: input.companyId, environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null,
|
|
provider: parsed.config.provider, providerLeaseId: cleanup.providerLeaseId,
|
|
metadata: cleanupMetadata,
|
|
},
|
|
cause: error,
|
|
canTeardown: pluginWorkerManager.isRunning(pluginProvider.resolved.plugin.id),
|
|
teardown: (pendingLeaseId) => destroyWithCreationObservation({
|
|
leaseId: pendingLeaseId, metadata: cleanupMetadata,
|
|
teardown: (metadata) => pluginWorkerManager.call(pluginProvider.resolved.plugin.id, "environmentDestroyLease", {
|
|
driverKey: parsed.config.provider, companyId: input.companyId,
|
|
environmentId: input.environment.id, issueId: input.issueId,
|
|
config: workerConfig, providerLeaseId: cleanup.providerLeaseId,
|
|
leaseMetadata: metadata,
|
|
}, resolvePluginSandboxRpcTimeoutMs(workerConfig)),
|
|
}),
|
|
});
|
|
if (cleanupConfirmed) {
|
|
throw new Error("Sandbox creation failed; allocated sandbox cleanup was confirmed.", { cause: error });
|
|
}
|
|
}
|
|
// A cleanup record is not a usable lease. Keep acquisition failed,
|
|
// even when its compensating deletion succeeds on this attempt.
|
|
throw error;
|
|
}
|
|
})();
|
|
|
|
// Ad-hoc test leases are never publishable for reuse: storing them
|
|
// as `reuse_by_environment` would let a concurrent heartbeat resume
|
|
// the test's provider lease and lose its sandbox when the test ends.
|
|
const resolvedLeasePolicy = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null
|
|
? "reuse_by_environment"
|
|
: "ephemeral";
|
|
const sanitizedProviderMetadata = stripSecretRefValuesFromPluginLeaseMetadata({
|
|
metadata: acquiredLease.metadata,
|
|
schema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | null | undefined,
|
|
});
|
|
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
|
|
? buildReusableSandboxLeaseScope({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: parsed.config.provider,
|
|
config: providerConfigForLease,
|
|
leaseFingerprint,
|
|
providerMetadata: sanitizedProviderMetadata,
|
|
})
|
|
: null;
|
|
|
|
const pluginLeaseMetadata = {
|
|
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
driver: input.environment.driver,
|
|
executionWorkspaceMode: input.executionWorkspaceMode,
|
|
pluginId: pluginProvider.resolved.plugin.id,
|
|
pluginKey: pluginProvider.resolved.plugin.pluginKey,
|
|
sandboxProviderPlugin: true,
|
|
...sandboxConfigForLeaseMetadata(storedConfig),
|
|
...sanitizedProviderMetadata,
|
|
sandboxLeaseAcquisition: providerLease
|
|
? {
|
|
outcome: "resumed",
|
|
}
|
|
: reusableLease?.providerLeaseId
|
|
? {
|
|
outcome: "replacement",
|
|
reason: replacementReason ?? "resume_failed",
|
|
}
|
|
: {
|
|
outcome: "created",
|
|
},
|
|
...(reusableLease?.metadata?.nativeHarnessBackup
|
|
? { nativeHarnessBackup: reusableLease.metadata.nativeHarnessBackup }
|
|
: {}),
|
|
...(providerLease && reusableLease?.metadata?.nativeWorkspaceSync
|
|
? {
|
|
nativeWorkspaceSync: reusableLease.metadata.nativeWorkspaceSync,
|
|
}
|
|
: {}),
|
|
...(reusableScope ? { reusableSandboxLease: reusableScope } : {}),
|
|
};
|
|
try {
|
|
return await environmentsSvc.acquireLease({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
assertCompanyBinding: input.assertCompanyBinding,
|
|
leasePolicy: resolvedLeasePolicy,
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: acquiredLease.providerLeaseId,
|
|
expiresAt: providerAttestedLeaseExpiry(
|
|
input.requestedExpiresAt,
|
|
acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined,
|
|
),
|
|
metadata: pluginLeaseMetadata,
|
|
reusesReusableLeaseId:
|
|
providerLease &&
|
|
reusableLease?.heartbeatRunId === input.heartbeatRunId
|
|
? reusableLease.id
|
|
: null,
|
|
replacesReusableLeaseId:
|
|
providerLease &&
|
|
reusableLease?.heartbeatRunId !== input.heartbeatRunId
|
|
? reusableLease?.id
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
// The conditional lease insert rejected, so no lease row exists. A
|
|
// managed reconciliation can bind the environment to another company
|
|
// between the route guard and this insert, so the insert fails closed
|
|
// with `environment_company_mismatch`. This call already provisioned the
|
|
// remote plugin sandbox above, so tear it down now. Without this step
|
|
// the rejected insert leaks a live sandbox that no lease row tracks. The
|
|
// Claude login runs on a plugin-backed sandbox, so this path is the one
|
|
// the login uses. Do not tear down a reused sandbox that an earlier
|
|
// lease still owns.
|
|
if (!reusableLease || acquiredLease.providerLeaseId !== reusableLease.providerLeaseId) {
|
|
// Record the durable pending-cleanup row before the teardown, then
|
|
// tear the sandbox down. On a successful teardown the handler releases
|
|
// the row. If the durable write and the teardown both fail, the
|
|
// handler throws a `SandboxOrphanCleanupWriteError` that carries the
|
|
// original rejection as its cause.
|
|
await cleanUpRejectedOrphanSandbox({
|
|
record: {
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issueId ?? null,
|
|
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: acquiredLease.providerLeaseId,
|
|
metadata: pluginLeaseMetadata,
|
|
},
|
|
cause: error,
|
|
canTeardown: pluginWorkerManager.isRunning(pluginProvider.resolved.plugin.id),
|
|
teardown: async () => {
|
|
return await pluginWorkerManager.call(
|
|
pluginProvider.resolved.plugin.id,
|
|
"environmentDestroyLease",
|
|
{
|
|
driverKey: parsed.config.provider,
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.issueId,
|
|
config: workerConfig,
|
|
providerLeaseId: acquiredLease.providerLeaseId,
|
|
leaseMetadata: acquiredLease.metadata ?? undefined,
|
|
},
|
|
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
|
);
|
|
},
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Built-in sandbox provider path. Same guard as the plugin-backed path:
|
|
// ad-hoc tests (heartbeatRunId === null) must never resume an existing
|
|
// provider lease, or releasing the test lease will terminate the live
|
|
// heartbeat run that shares it. Filter to reusable policies and statuses
|
|
// so non-reusable, cleanup-pending, or terminal rows can never be matched.
|
|
const builtinSandboxProvider = getBuiltinSandboxProvider(parsed.config.provider);
|
|
// Resolve the DECLARED reusable-lease capability through the same contract
|
|
// as the plugin path, so the nested capability wins over the legacy flag.
|
|
const supportsReusableLeases =
|
|
resolveDeclaredSandboxCapabilities({
|
|
supportsReusableLeases: builtinSandboxProvider?.supportsReusableLeases,
|
|
}).reusableLeases === true;
|
|
const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config);
|
|
const leaseFingerprint =
|
|
supportsReusableLeases &&
|
|
parsed.config.reuseLease &&
|
|
input.heartbeatRunId !== null &&
|
|
(input.executionWorkspaceId !== null || input.issueId !== null) &&
|
|
input.agentId !== null
|
|
? await buildReusableSandboxLeaseFingerprint({
|
|
db,
|
|
companyId: input.companyId,
|
|
environment: input.environment,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: parsed.config.provider,
|
|
providerConfig: providerConfigForLease,
|
|
})
|
|
: null;
|
|
const reusableCandidateLeases =
|
|
supportsReusableLeases &&
|
|
parsed.config.reuseLease &&
|
|
input.heartbeatRunId !== null &&
|
|
(input.executionWorkspaceId !== null || input.issueId !== null) &&
|
|
input.agentId !== null
|
|
? (await environmentsSvc.listLeases(input.environment.id))
|
|
.filter((lease) =>
|
|
lease.leasePolicy === "reuse_by_environment" &&
|
|
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
|
|
lease.executionWorkspaceId === input.executionWorkspaceId &&
|
|
(input.executionWorkspaceId !== null || lease.issueId === input.issueId) &&
|
|
lease.metadata?.agentId === input.agentId,
|
|
)
|
|
: [];
|
|
const reusableExistingLeases = reusableCandidateLeases.filter((lease) =>
|
|
reusableSandboxLeaseScopeMatches({
|
|
lease,
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: parsed.config.provider,
|
|
config: providerConfigForLease,
|
|
leaseFingerprint,
|
|
allowLegacyRuntimeFingerprint:
|
|
lease.status === "active" &&
|
|
input.heartbeatRunId !== null &&
|
|
lease.heartbeatRunId === input.heartbeatRunId,
|
|
}),
|
|
);
|
|
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
|
|
await cleanupObsoleteReusableSandboxLeases({
|
|
environment: input.environment,
|
|
leases: reusableCandidateLeases,
|
|
reusableLeases: reusableExistingLeases,
|
|
});
|
|
}
|
|
const reusableProviderLeaseId =
|
|
supportsReusableLeases &&
|
|
parsed.config.reuseLease &&
|
|
input.heartbeatRunId !== null &&
|
|
(input.executionWorkspaceId !== null || input.issueId !== null) &&
|
|
input.agentId !== null
|
|
? findReusableSandboxLeaseId({ config: parsed.config, leases: reusableExistingLeases })
|
|
: null;
|
|
const reusableLease = reusableProviderLeaseId
|
|
? reusableExistingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
|
|
: null;
|
|
|
|
let providerLease;
|
|
try {
|
|
providerLease = await acquireSandboxProviderLease({
|
|
config: parsed.config,
|
|
environmentId: input.environment.id,
|
|
heartbeatRunId: input.heartbeatRunId ?? randomUUID(),
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
reusableProviderLeaseId,
|
|
// Forward a caller deadline so the provider configures a provider-side
|
|
// expiry at or before it and returns the real provider expiry.
|
|
requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt),
|
|
});
|
|
} catch (error) {
|
|
if (reusableLease) {
|
|
throw new ReusableSandboxResumeError({
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: reusableLease.providerLeaseId!,
|
|
cause: error,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
if (reusableLease && providerLease.providerLeaseId !== reusableLease.providerLeaseId) {
|
|
await destroyReusableSandboxLease({
|
|
environment: input.environment,
|
|
lease: reusableLease,
|
|
failureReason: "resume_failed",
|
|
});
|
|
}
|
|
|
|
// Same ephemeral-policy-for-tests guard as the plugin-backed path:
|
|
// ad-hoc test leases must not be publishable for reuse.
|
|
const resolvedLeasePolicy = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null
|
|
? "reuse_by_environment"
|
|
: "ephemeral";
|
|
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
|
|
? buildReusableSandboxLeaseScope({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
provider: parsed.config.provider,
|
|
config: providerConfigForLease,
|
|
leaseFingerprint,
|
|
providerMetadata: providerLease.metadata,
|
|
})
|
|
: null;
|
|
|
|
const builtinLeaseMetadata = {
|
|
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
driver: input.environment.driver,
|
|
executionWorkspaceMode: input.executionWorkspaceMode,
|
|
...providerLease.metadata,
|
|
sandboxLeaseAcquisition:
|
|
reusableLease && providerLease.providerLeaseId === reusableLease.providerLeaseId
|
|
? {
|
|
outcome: "resumed",
|
|
}
|
|
: reusableLease?.providerLeaseId
|
|
? {
|
|
outcome: "replacement",
|
|
reason: "resume_failed",
|
|
}
|
|
: {
|
|
outcome: "created",
|
|
},
|
|
...(reusableLease?.metadata?.nativeHarnessBackup
|
|
? { nativeHarnessBackup: reusableLease.metadata.nativeHarnessBackup }
|
|
: {}),
|
|
...(reusableLease &&
|
|
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
|
|
reusableLease.metadata?.nativeWorkspaceSync
|
|
? {
|
|
nativeWorkspaceSync: reusableLease.metadata.nativeWorkspaceSync,
|
|
}
|
|
: {}),
|
|
...(reusableScope ? { reusableSandboxLease: reusableScope } : {}),
|
|
};
|
|
try {
|
|
return await environmentsSvc.acquireLease({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
assertCompanyBinding: input.assertCompanyBinding,
|
|
leasePolicy: resolvedLeasePolicy,
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: providerLease.providerLeaseId,
|
|
expiresAt: providerAttestedLeaseExpiry(
|
|
input.requestedExpiresAt,
|
|
providerLease.expiresAt ? new Date(providerLease.expiresAt) : undefined,
|
|
),
|
|
metadata: builtinLeaseMetadata,
|
|
reusesReusableLeaseId:
|
|
reusableLease &&
|
|
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
|
|
reusableLease.heartbeatRunId === input.heartbeatRunId
|
|
? reusableLease.id
|
|
: null,
|
|
replacesReusableLeaseId:
|
|
reusableLease &&
|
|
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
|
|
reusableLease.heartbeatRunId !== input.heartbeatRunId
|
|
? reusableLease.id
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
// The conditional lease insert rejected, so no lease row exists. A managed
|
|
// reconciliation can bind the environment to another company between the
|
|
// route guard and this insert, so the insert fails closed with
|
|
// `environment_company_mismatch`. This call already provisioned the remote
|
|
// sandbox above, so release it now. Without this teardown the rejected
|
|
// insert leaks a live sandbox that no lease row tracks. Do not tear down a
|
|
// reused sandbox that an earlier lease still owns.
|
|
if (!reusableLease || providerLease.providerLeaseId !== reusableLease.providerLeaseId) {
|
|
// Record the durable pending-cleanup row before the teardown, then
|
|
// release the remote sandbox. On a successful teardown the handler
|
|
// releases the row. If the durable write and the teardown both fail,
|
|
// the handler throws a `SandboxOrphanCleanupWriteError` that carries the
|
|
// original rejection as its cause.
|
|
await cleanUpRejectedOrphanSandbox({
|
|
record: {
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issueId ?? null,
|
|
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
provider: parsed.config.provider,
|
|
providerLeaseId: providerLease.providerLeaseId,
|
|
metadata: builtinLeaseMetadata,
|
|
},
|
|
cause: error,
|
|
canTeardown: true,
|
|
teardown: async () => {
|
|
await destroySandboxProviderLease({
|
|
config: parsed.config,
|
|
providerLeaseId: providerLease.providerLeaseId,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async releaseRunLease(input) {
|
|
if (input.status === "expired" && input.lease.leasePolicy === "reuse_by_environment") {
|
|
return await destroyReusableSandboxLease({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
failureReason: "lease_expired",
|
|
});
|
|
}
|
|
|
|
// Check if this lease was acquired through a plugin.
|
|
if (input.lease.metadata?.sandboxProviderPlugin) {
|
|
return await releasePluginBackedSandboxLease(input);
|
|
}
|
|
|
|
const metadataConfig = sandboxConfigFromLeaseMetadata(input.lease);
|
|
|
|
// If no built-in provider handles this metadata, try plugin path.
|
|
if (!metadataConfig) {
|
|
const looseConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
|
if (looseConfig && !isBuiltinSandboxProvider(looseConfig.provider)) {
|
|
return await releasePluginBackedSandboxLease(input);
|
|
}
|
|
}
|
|
|
|
const parsed = metadataConfig
|
|
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
|
id: input.environment.id,
|
|
driver: "sandbox",
|
|
config: metadataConfig as unknown as Record<string, unknown>,
|
|
})
|
|
: await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, input.environment);
|
|
if (parsed.driver !== "sandbox") {
|
|
throw new Error(`Expected sandbox environment config for lease "${input.lease.id}".`);
|
|
}
|
|
|
|
let cleanupStatus: "success" | "failed" = "success";
|
|
try {
|
|
await releaseSandboxProviderLease({
|
|
config: parsed.config,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
status: input.status,
|
|
});
|
|
} catch {
|
|
cleanupStatus = "failed";
|
|
}
|
|
const releaseStatus = input.lease.leasePolicy === "retain_on_failure" && input.status === "failed"
|
|
? "retained" as const
|
|
: input.status;
|
|
return await environmentsSvc.releaseLease(input.lease.id, releaseStatus, {
|
|
failureReason: input.status === "failed" ? "adapter_or_run_failure" : undefined,
|
|
cleanupStatus,
|
|
});
|
|
},
|
|
|
|
async retryPendingSandboxTeardown(input) {
|
|
const exportResume = hasStopOnlyCleanup(input.lease);
|
|
const resumeIntent = readStopOnlyCleanup(input.lease);
|
|
const assertExportResumeOwnership = async () => {
|
|
if (!resumeIntent) throw new Error("Workspace export resume ownership is invalid.");
|
|
const [current] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, input.lease.id)).limit(1);
|
|
const [otherOwner] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
|
|
ne(environmentLeases.id, input.lease.id), eq(environmentLeases.provider, input.lease.provider!),
|
|
eq(environmentLeases.providerLeaseId, input.lease.providerLeaseId!), inArray(environmentLeases.status, ["active", "retained", "pending_cleanup"]),
|
|
)).limit(1);
|
|
if (!current || current.status !== "pending_cleanup" || current.companyId !== input.lease.companyId
|
|
|| current.metadata?.pendingCleanupAttemptId !== input.lease.metadata?.pendingCleanupAttemptId
|
|
|| readStopOnlyCleanup(current)?.requestId !== resumeIntent.requestId
|
|
|| readStopOnlyCleanup(current)?.pluginId !== resumeIntent.pluginId || otherOwner) {
|
|
throw new Error("Workspace export resume ownership changed before cleanup.");
|
|
}
|
|
};
|
|
if (exportResume) await assertExportResumeOwnership();
|
|
// Resolve the teardown from the immutable orphan lease row, not from the
|
|
// current environment. The row keeps the provider, the provider lease id,
|
|
// and the sandbox config in its metadata. A provider change re-points the
|
|
// environment, and an environment delete removes it, but neither must
|
|
// strand this teardown. So the retry reads the recorded provider and the
|
|
// recorded config, and never the current environment provider.
|
|
const recordedProvider =
|
|
input.lease.provider ??
|
|
(typeof input.lease.metadata?.provider === "string" ? input.lease.metadata.provider : null);
|
|
if (!recordedProvider) {
|
|
throw new Error(`Pending-cleanup lease "${input.lease.id}" has no recorded provider for teardown.`);
|
|
}
|
|
|
|
// Build the config from the lease metadata under the orphan's company
|
|
// scope, so the retry resolves the same connection secrets the failed
|
|
// acquire used. The recorded config is the sole source of truth here: a
|
|
// delete removed the environment, and a provider change re-points it, so
|
|
// the retry never reads the current environment config.
|
|
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
|
if (!metadataConfig || metadataConfig.provider !== recordedProvider) {
|
|
throw new Error(
|
|
`Pending-cleanup lease "${input.lease.id}" has no recorded config for provider "${recordedProvider}".`,
|
|
);
|
|
}
|
|
|
|
// Plugin-backed provider path. The Claude login runs on a plugin-backed
|
|
// sandbox, so this path tears down the login orphan.
|
|
if (!isBuiltinSandboxProvider(recordedProvider)) {
|
|
if (!pluginWorkerManager) {
|
|
throw new Error(
|
|
`Sandbox provider "${recordedProvider}" needs a plugin worker manager for cleanup, but none is available.`,
|
|
);
|
|
}
|
|
const pinnedPluginId = exportResume ? readString(input.lease.metadata?.pluginId) : null;
|
|
if (exportResume && !pinnedPluginId) throw new Error("Workspace export cleanup has no recorded provider plugin.");
|
|
const pluginProvider = await resolveSandboxProviderPlugin({ provider: recordedProvider, ...(pinnedPluginId ? { pluginId: pinnedPluginId } : {}) });
|
|
if (pluginProvider.state !== "running") {
|
|
throw new Error(
|
|
`Sandbox provider plugin for "${recordedProvider}" is ${pluginProvider.state}, so the cleanup teardown cannot run yet.`,
|
|
);
|
|
}
|
|
// Resolve the recorded secret refs through the durable orphan record,
|
|
// not the environment binding. A delete removed the binding, or a
|
|
// provider change replaced it, so environment-bound resolution would
|
|
// reject the recorded API-key ref and strand the teardown forever.
|
|
const config = await resolveSandboxCleanupConfigSecrets(
|
|
db,
|
|
input.lease.companyId,
|
|
metadataConfig,
|
|
{ issueId: input.lease.issueId, heartbeatRunId: input.lease.heartbeatRunId },
|
|
);
|
|
const workerConfig = stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig);
|
|
if (exportResume) {
|
|
const pluginId = pluginProvider.resolved.plugin.id;
|
|
if (!pluginWorkerVerifiesLifecycleMethod(pluginId, "environmentStopLease")) {
|
|
throw new Error("Workspace export recovery requires verified stop-only cleanup.");
|
|
}
|
|
await assertExportResumeOwnership();
|
|
const receipt = await runLeaseReleaseWithRunParent(input.lease.id, () => pluginWorkerManager.call(pluginId, "environmentStopLease", {
|
|
driverKey: recordedProvider, companyId: input.lease.companyId,
|
|
environmentId: input.lease.environmentId ?? "", issueId: input.lease.issueId,
|
|
config: workerConfig, providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: input.lease.metadata ?? {}, cancelActiveWork: true, resourceDisposition: "stop_and_retain",
|
|
}, Math.min(resolvePluginSandboxRpcTimeoutMs(workerConfig) ?? 60_000, 60_000)));
|
|
if (remoteTerminationReceipt(input.lease, receipt)?.state !== "stopped") {
|
|
throw new Error("Workspace export recovery did not confirm the retained sandbox stopped.");
|
|
}
|
|
return receipt;
|
|
}
|
|
const failedCreation = readEnvironmentCreationCleanupError({ data: {
|
|
schema: "paperclip/environment-creation-cleanup/v1",
|
|
cleanup: input.lease.metadata?.failedCreateCleanup,
|
|
} });
|
|
// An environment deletion clears the lease FK. Retain the original
|
|
// ownership scope for uncertain allocations without accepting a foreign
|
|
// company, provider name, or changed environment from metadata.
|
|
if (failedCreation && (failedCreation.companyId !== input.lease.companyId ||
|
|
failedCreation.providerLeaseId !== input.lease.providerLeaseId ||
|
|
(input.lease.environmentId !== null && failedCreation.environmentId !== input.lease.environmentId))) {
|
|
throw new Error("Pending creation cleanup ownership does not match the lease");
|
|
}
|
|
const teardown = (metadata: Record<string, unknown>) => pluginWorkerManager.call(
|
|
pluginProvider.resolved.plugin.id,
|
|
"environmentDestroyLease",
|
|
{
|
|
driverKey: recordedProvider,
|
|
companyId: input.lease.companyId,
|
|
environmentId: failedCreation?.environmentId ?? input.lease.environmentId ?? input.environment?.id ?? "",
|
|
issueId: input.lease.issueId,
|
|
config: workerConfig,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: metadata,
|
|
},
|
|
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
|
);
|
|
return failedCreation
|
|
? await destroyWithCreationObservation({ leaseId: input.lease.id, metadata: input.lease.metadata ?? {}, teardown })
|
|
: await teardown(input.lease.metadata ?? {});
|
|
}
|
|
|
|
// Built-in provider path. Resolve the recorded config secrets through the
|
|
// durable orphan record, not the environment binding, for the same reason
|
|
// as the plugin path above. The teardown targets the recorded provider,
|
|
// never the current environment provider.
|
|
const cleanupConfig = await resolveSandboxCleanupConfigSecrets(
|
|
db,
|
|
input.lease.companyId,
|
|
metadataConfig,
|
|
{ issueId: input.lease.issueId, heartbeatRunId: input.lease.heartbeatRunId },
|
|
);
|
|
if (exportResume) {
|
|
const provider = getBuiltinSandboxProvider(recordedProvider);
|
|
if (resumeIntent?.pluginId !== null || !provider?.stopLease) {
|
|
throw new Error("Sandbox preservation requires verified built-in stop-only cleanup.");
|
|
}
|
|
await assertExportResumeOwnership();
|
|
const receipt = await provider.stopLease({ config: cleanupConfig, providerLeaseId: input.lease.providerLeaseId });
|
|
if (remoteTerminationReceipt(input.lease, receipt)?.state !== "stopped") {
|
|
throw new Error("Built-in sandbox stop did not confirm the exact retained allocation.");
|
|
}
|
|
return receipt;
|
|
}
|
|
await destroySandboxProviderLease({
|
|
config: cleanupConfig,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
});
|
|
},
|
|
|
|
async isPendingCleanupWorkerReady(input) {
|
|
// Resolve the recorded provider the same way `retryPendingSandboxTeardown`
|
|
// does, so the probe reads the same target the teardown uses.
|
|
const recordedProvider =
|
|
input.lease.provider ??
|
|
(typeof input.lease.metadata?.provider === "string" ? input.lease.metadata.provider : null);
|
|
// A missing provider is a permanent misconfiguration. Report ready, so the
|
|
// teardown runs, throws, and counts toward the cap.
|
|
if (!recordedProvider) return true;
|
|
// A built-in provider has no plugin worker, so it is always ready.
|
|
if (isBuiltinSandboxProvider(recordedProvider)) {
|
|
return !hasStopOnlyCleanup(input.lease) || typeof getBuiltinSandboxProvider(recordedProvider)?.stopLease === "function";
|
|
}
|
|
// No worker manager is a permanent condition here. Report ready, so the
|
|
// teardown runs, throws its own "no worker manager" error, and counts
|
|
// toward the cap.
|
|
if (!pluginWorkerManager) return true;
|
|
if (hasStopOnlyCleanup(input.lease)) {
|
|
const pinnedPluginId = readString(input.lease.metadata?.pluginId);
|
|
if (!pinnedPluginId) return true; // Permanent invalid intent, never a by-key fallback.
|
|
const pinned = await resolvePluginSandboxProviderDriverById({ db, pluginId: pinnedPluginId, driverKey: recordedProvider });
|
|
return Boolean(pinned?.plugin.status === "ready" && pluginWorkerManager.isRunning(pinned.plugin.id)
|
|
&& pluginWorkerVerifiesLifecycleMethod(pinned.plugin.id, "environmentStopLease"));
|
|
}
|
|
// Resolve the installed plugin without a wait. A plugin reload or a plugin
|
|
// reinstall can remove the plugin row for a short window, so a missing
|
|
// plugin is a transient condition, not a permanent one. Report not ready,
|
|
// so the sweep skips the lease without a claim, and a later sweep retries
|
|
// after the plugin returns. A teardown while the plugin is missing only
|
|
// throws and burns a finite attempt, so a long reload could exhaust the
|
|
// retries and strand the sandbox. A permanent uninstall also cannot tear
|
|
// the sandbox down, because there is no worker to call, so the preserved
|
|
// pending_cleanup row still holds the durable cleanup state for later.
|
|
const installed = await resolvePluginSandboxProviderDriverByKey({
|
|
db,
|
|
driverKey: recordedProvider,
|
|
workerManager: pluginWorkerManager,
|
|
requireRunning: false,
|
|
});
|
|
if (!installed) return false;
|
|
// The plugin is installed but not ready yet. A plugin reload or a plugin
|
|
// reinstall moves the plugin through this state, so it is a transient
|
|
// window, not a permanent condition. Report not ready, so the sweep skips
|
|
// the lease without a claim, and a later sweep retries after the plugin
|
|
// becomes ready. A teardown here only throws "is not_ready" and burns a
|
|
// finite attempt, so a long reload could exhaust the retries and strand the
|
|
// sandbox.
|
|
if (installed.plugin.status !== "ready") return false;
|
|
// The plugin is installed and ready, so gate on the live worker. A running
|
|
// worker is ready. A down worker is the transient restart window, so report
|
|
// not ready and let a later sweep retry after the worker recovers.
|
|
return pluginWorkerManager.isRunning(installed.plugin.id);
|
|
},
|
|
|
|
flushDeferredOrphanCleanups,
|
|
|
|
async realizeWorkspace(input) {
|
|
// Resolve the realized cwd and any provider metadata first, then build ONE
|
|
// workspace-realization record and wrap it the SAME way for every driver. A
|
|
// plugin-backed sandbox provider realizes the workspace remotely and returns its
|
|
// own cwd and metadata. A built-in driver has no plugin call; it uses the lease
|
|
// `remoteCwd`. Both paths must produce the record through the single build below,
|
|
// so the record can never drift between two exits.
|
|
let pluginRealizedCwd: string | null = null;
|
|
let providerMetadata: Record<string, unknown> | null = null;
|
|
if (input.lease.metadata?.sandboxProviderPlugin && pluginWorkerManager) {
|
|
const pluginId = readString(input.lease.metadata?.pluginId);
|
|
const providerKey =
|
|
readString(input.lease.metadata?.provider) ??
|
|
(input.environment.driver === "sandbox"
|
|
? (parseEnvironmentDriverConfig(input.environment).config as SandboxEnvironmentConfig).provider
|
|
: null);
|
|
if (pluginId && providerKey) {
|
|
const config = await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
const pluginResult = await pluginWorkerManager.call(pluginId, "environmentRealizeWorkspace", {
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
|
|
lease: {
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: input.lease.metadata ?? undefined,
|
|
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
|
},
|
|
workspace: input.workspace,
|
|
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig)));
|
|
pluginRealizedCwd =
|
|
typeof pluginResult.cwd === "string" && pluginResult.cwd.trim().length > 0
|
|
? pluginResult.cwd.trim()
|
|
: null;
|
|
providerMetadata = pluginResult.metadata ?? null;
|
|
}
|
|
}
|
|
|
|
// A plugin realize handler returns only its realized cwd and provider metadata; it
|
|
// does not build the full workspace-realization record. The server builds that record
|
|
// from the run request, so the referenced (mentioned) project sources reach the adapter
|
|
// through `realization.additional`. The adapter reads that field to stage each referenced
|
|
// tree into the sandbox; without the record the sandbox agent never receives the mentioned
|
|
// projects. The provider cwd and metadata still drive the remote path when a plugin realizes
|
|
// the workspace.
|
|
const record = buildWorkspaceRealizationRecordFromDriverInput({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
workspace: input.workspace,
|
|
cwd:
|
|
pluginRealizedCwd ??
|
|
(typeof input.lease.metadata?.remoteCwd === "string" && input.lease.metadata.remoteCwd.trim().length > 0
|
|
? input.lease.metadata.remoteCwd.trim()
|
|
: input.workspace.remotePath ?? input.workspace.localPath ?? null),
|
|
providerMetadata,
|
|
});
|
|
return {
|
|
cwd: pluginRealizedCwd ?? record.remote.path ?? record.local.path,
|
|
metadata: {
|
|
...(providerMetadata ?? {}),
|
|
workspaceRealization: record,
|
|
},
|
|
};
|
|
},
|
|
|
|
async resumeRunLease(input) {
|
|
const pluginId = readString(input.lease.metadata?.pluginId);
|
|
const providerKey = readString(input.lease.metadata?.provider);
|
|
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager || !pluginId || !providerKey
|
|
|| !input.lease.providerLeaseId || input.lease.environmentId !== input.environment.id
|
|
|| !pluginWorkerVerifiesLifecycleMethod(pluginId, "environmentResumeLease")
|
|
|| (hasNativeWorkspaceExportResume(input.lease) && !pluginWorkerVerifiesLifecycleMethod(pluginId, "environmentStopLease"))) {
|
|
throw new Error("The exact sandbox lease cannot be resumed by its verified provider.");
|
|
}
|
|
// This is a lifecycle resume, never acquisition: no replacement, host
|
|
// seed, lease mutation, or provider turn is allowed at this boundary.
|
|
const config = stripSandboxProviderEnvelope(await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment, lease: input.lease, provider: providerKey,
|
|
}) as SandboxEnvironmentConfig);
|
|
const resumed = await pluginWorkerManager.call(pluginId, "environmentResumeLease", {
|
|
driverKey: providerKey, companyId: input.lease.companyId, environmentId: input.environment.id,
|
|
issueId: input.lease.issueId, config, providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: input.lease.metadata ?? undefined,
|
|
}, Math.min(resolvePluginSandboxRpcTimeoutMs(config) ?? 60_000, 60_000));
|
|
if (resumed?.providerLeaseId !== input.lease.providerLeaseId) {
|
|
throw new Error("The provider did not confirm the exact retained sandbox. No replacement was acquired.");
|
|
}
|
|
return resumed;
|
|
},
|
|
|
|
async execute(input) {
|
|
// Plugin-backed sandbox providers: delegate command execution.
|
|
if (input.lease.metadata?.sandboxProviderPlugin && pluginWorkerManager) {
|
|
// Read the active run-parent context once. The host mints the plugin
|
|
// RPC `traceparent` from this same context, so a provider `session.setup`
|
|
// span parents to the run trace only when this context is present.
|
|
const activeStep = getActiveStepContext();
|
|
// Record the run-time exec parent context for this lease, so a later
|
|
// lease-release RPC that emits the provider `session.teardown` span can
|
|
// parent to the same run trace. This is the same context the
|
|
// `sandbox.exec` span reads. Keep only a defined context; a local or SSH
|
|
// target with no host trace context yields undefined and stores nothing.
|
|
const execParentContext = activeStep?.parentContext;
|
|
if (execParentContext !== undefined) {
|
|
runExecParentByLeaseId.set(input.lease.id, execParentContext);
|
|
}
|
|
// Bypass the persistent session for any command that runs with no active
|
|
// run-parent context. Such a command runs before the run trace is active:
|
|
// the workspace provision command, the CLI install command, the
|
|
// resolvability probe, and the agent process launch all run at the top of
|
|
// the adapter execute, outside a measured step and outside the run
|
|
// trace. A session opened on such a command emits a `session.setup` span
|
|
// with no host-minted parent, and the span backend drops it. So run the
|
|
// command one-shot and keep the session closed; the session then opens on
|
|
// the first in-run command that carries a run parent (an agent tool
|
|
// command runs under the run trace), whose setup span parents to the run
|
|
// trace. A command that sets `bypassSession` explicitly always bypasses.
|
|
// A command that sets `forceSession` keeps the session even with no
|
|
// active step: the ACP process session bridge runs the long-lived agent
|
|
// command this way, so the session opens and streams its output through
|
|
// the session log stream. An explicit `bypassSession` still wins.
|
|
const bypassSession =
|
|
input.bypassSession === true || (activeStep === null && input.forceSession !== true);
|
|
const pluginId = readString(input.lease.metadata?.pluginId);
|
|
const providerKey = readString(input.lease.metadata?.provider);
|
|
if (pluginId && providerKey) {
|
|
const config = await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
const sanitizedConfig = stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig);
|
|
return await pluginWorkerManager.call(pluginId, "environmentExecute", {
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: sanitizedConfig,
|
|
lease: {
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: input.lease.metadata ?? undefined,
|
|
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
|
},
|
|
command: input.command,
|
|
args: input.args,
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
stdin: input.stdin,
|
|
timeoutMs: input.timeoutMs,
|
|
// Forward the effective session-bypass flag so a provider that opens
|
|
// a persistent session skips it for a pre-run or context-less command
|
|
// (the workspace provision command, the CLI install command, the
|
|
// resolvability probe, the agent process launch). The session then
|
|
// opens on the first in-run command that carries a run parent, whose
|
|
// setup span parents to the run trace.
|
|
bypassSession,
|
|
}, resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: input.timeoutMs,
|
|
config: sanitizedConfig,
|
|
}), input.onLog);
|
|
}
|
|
}
|
|
throw new Error("Sandbox driver does not support direct command execution for built-in providers.");
|
|
},
|
|
|
|
supportsSync(input) {
|
|
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) return false;
|
|
const pluginId = readString(input.lease.metadata?.pluginId);
|
|
if (!pluginId) return false;
|
|
const advertised = pluginWorkerManager.getWorker(pluginId)?.supportedMethods ?? [];
|
|
if (!advertised.includes("environmentSyncIn") || !advertised.includes("environmentSyncOut")) {
|
|
return false;
|
|
}
|
|
// A worker advertises the sync verbs process-wide, but an individual lease
|
|
// may run on a backend that has no data channel for the native transport
|
|
// (e.g. a batch/job backend whose sync hook rejects immediately). The
|
|
// provider flags such leases so they keep the byte-identical base64
|
|
// fallback instead of being routed to a hook that would only error.
|
|
//
|
|
// Also fall back for any lease persisted with `backend: "job"` directly:
|
|
// job leases created before `nativeFileSyncUnsupported` existed carry the
|
|
// backend field but not the flag, and the `job` backend has no pod-exec
|
|
// channel, so routing them to the native hook would only reject.
|
|
if (
|
|
input.lease.metadata?.nativeFileSyncUnsupported === true ||
|
|
input.lease.metadata?.backend === "job"
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
async syncIn(input) {
|
|
return await callPluginEnvironmentSync("environmentSyncIn", input);
|
|
},
|
|
|
|
async syncOut(input) {
|
|
return await callPluginEnvironmentSync("environmentSyncOut", input);
|
|
},
|
|
|
|
async openDuplexChannel(input) {
|
|
// Plugin-backed sandbox providers only: open the host-owned duplex route on
|
|
// the plugin worker. The lease scope mirrors the sandbox execute path — the
|
|
// provider driver key, the company, the environment, and the provider lease
|
|
// id — so the route binds to the same worker session the runner streams.
|
|
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) {
|
|
throw new Error("Sandbox driver does not support duplex channels for this lease.");
|
|
}
|
|
const pluginId = readString(input.lease.metadata?.pluginId);
|
|
const providerKey = readString(input.lease.metadata?.provider);
|
|
const providerLeaseId = readString(input.lease.providerLeaseId);
|
|
if (!pluginId || !providerKey || !providerLeaseId) {
|
|
throw new Error(
|
|
"Sandbox duplex channel needs a plugin id, a provider key, and a provider lease id on the lease.",
|
|
);
|
|
}
|
|
const worker = pluginWorkerManager.getWorker(pluginId);
|
|
if (!worker) {
|
|
throw new Error(`Plugin worker "${pluginId}" is not running for the duplex channel.`);
|
|
}
|
|
const managerInput: WorkerManagerDuplexChannelOpenInput = {
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
providerLeaseId,
|
|
command: input.command,
|
|
};
|
|
const session = await worker.openDuplexChannel(managerInput);
|
|
return adaptDuplexChannelHostSession(session);
|
|
},
|
|
|
|
async resolveCapabilities(input) {
|
|
return await resolveSandboxCapabilitiesForLease(input);
|
|
},
|
|
|
|
async getRunnerIngressEndpoint(input) {
|
|
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) {
|
|
throw new Error("Sandbox driver does not support runner ingress for this lease.");
|
|
}
|
|
const pluginId = readString(input.lease.metadata.pluginId);
|
|
const providerKey = readString(input.lease.metadata.provider);
|
|
if (!pluginId || !providerKey || !input.lease.providerLeaseId) {
|
|
throw new Error("Sandbox runner ingress is missing its provider identity.");
|
|
}
|
|
const config = await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
const sanitizedConfig = stripSandboxProviderEnvelope(
|
|
config as SandboxEnvironmentConfig,
|
|
);
|
|
const acquire = async (): Promise<RunnerIngressEndpoint> => {
|
|
const result = await pluginWorkerManager.call(
|
|
pluginId,
|
|
"environmentRunnerIngressEndpoint",
|
|
{
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: sanitizedConfig,
|
|
lease: {
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: input.lease.metadata ?? undefined,
|
|
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
|
},
|
|
port: input.port,
|
|
path: input.path,
|
|
},
|
|
resolvePluginSandboxRpcTimeoutMs(sanitizedConfig),
|
|
);
|
|
const endpointUrl = new URL(result.websocketUrl);
|
|
if (
|
|
result.kind !== "authenticated_websocket" ||
|
|
endpointUrl.protocol !== "wss:" ||
|
|
endpointUrl.username ||
|
|
endpointUrl.password ||
|
|
endpointUrl.search ||
|
|
endpointUrl.hash ||
|
|
endpointUrl.pathname !== input.path ||
|
|
!result.generation
|
|
) {
|
|
throw new Error("Sandbox provider returned an invalid runner ingress endpoint.");
|
|
}
|
|
const secretHeaders = result.secretHeaders.map((header) => {
|
|
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(header.name) || !header.value) {
|
|
throw new Error("Sandbox provider returned an invalid runner ingress secret header.");
|
|
}
|
|
const secretHeader = { name: header.name } as {
|
|
name: string;
|
|
readonly value: string;
|
|
toJSON(): { name: string; value: "[REDACTED]" };
|
|
};
|
|
Object.defineProperty(secretHeader, "value", {
|
|
enumerable: false,
|
|
configurable: false,
|
|
writable: false,
|
|
value: header.value,
|
|
});
|
|
Object.defineProperty(secretHeader, "toJSON", {
|
|
enumerable: false,
|
|
configurable: false,
|
|
writable: false,
|
|
value: () => ({ name: header.name, value: "[REDACTED]" as const }),
|
|
});
|
|
return Object.freeze(secretHeader);
|
|
});
|
|
return {
|
|
kind: "authenticated_websocket",
|
|
websocketUrl: endpointUrl.toString(),
|
|
secretHeaders: Object.freeze(secretHeaders),
|
|
generation: result.generation,
|
|
refresh: acquire,
|
|
close: async () => undefined,
|
|
};
|
|
};
|
|
return await acquire();
|
|
},
|
|
|
|
async destroyRunLease(input) {
|
|
return await destroyReusableSandboxLease({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
failureReason: input.failureReason ?? "lease_destroyed",
|
|
});
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Resolve the effective capability snapshot for one sandbox lease. This is
|
|
* the sandbox driver's implementation of the general capability-resolution
|
|
* method: it gates through {@link ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT}'s
|
|
* `sandbox` support (the whole set, so this gate adds no restriction beyond
|
|
* declaration, verification, and narrowing), then reads the per-lease
|
|
* declaration, the live verified worker methods, and the per-lease
|
|
* narrowing, exactly as the runtime resolved them before this method
|
|
* existed.
|
|
*/
|
|
async function resolveSandboxCapabilitiesForLease(
|
|
input: EnvironmentDriverLeaseInput,
|
|
): Promise<EffectiveExecutionCapabilities> {
|
|
const metadata = input.lease.metadata ?? {};
|
|
const providerKey =
|
|
readString(metadata.provider) ??
|
|
(input.environment.driver === "sandbox"
|
|
? readString((parseEnvironmentDriverConfig(input.environment).config as SandboxEnvironmentConfig).provider)
|
|
: null);
|
|
if (!providerKey) {
|
|
return classifyEnvironmentCapabilities({
|
|
verifiedMethods: [],
|
|
declared: null,
|
|
narrowing: null,
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities,
|
|
});
|
|
}
|
|
|
|
let declared: SandboxProviderCapabilities | null = null;
|
|
let verifiedMethods: readonly string[] = [];
|
|
let configResolutionFailed = false;
|
|
|
|
if (metadata.sandboxProviderPlugin) {
|
|
const pluginId = readString(metadata.pluginId);
|
|
// Read the declaration from the exact plugin that acquired the lease,
|
|
// not the first installed plugin with this driver key. A driver key is
|
|
// only unique inside one manifest, so two plugins can share it. The
|
|
// by-key resolver could intersect this lease's verified methods with a
|
|
// different plugin's declaration. This resolver fails closed: it returns
|
|
// null when the pinned plugin id is absent, or when that exact plugin no
|
|
// longer declares this provider key with the `sandbox_provider` kind.
|
|
const resolvedDriver = pluginId
|
|
? await resolvePluginSandboxProviderDriverById({
|
|
db,
|
|
pluginId,
|
|
driverKey: providerKey,
|
|
})
|
|
: null;
|
|
if (!pluginId || !resolvedDriver) {
|
|
// Exact-plugin identity failure. The lease pins a plugin id, but the
|
|
// id is missing, that plugin is absent, or it no longer declares this
|
|
// provider key. Fail closed: resolve every effective capability to
|
|
// false, no matter what methods a stale or running worker still
|
|
// advertises. Do not read the worker methods here; passing them would
|
|
// let an identity-less lease keep a verified baseline. This differs
|
|
// from a valid plugin whose manifest merely omits
|
|
// `sandboxCapabilities`: that case keeps `declared` null below and
|
|
// defers to verified worker discovery.
|
|
return classifyEnvironmentCapabilities({
|
|
verifiedMethods: [],
|
|
declared: null,
|
|
narrowing: null,
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities,
|
|
});
|
|
}
|
|
verifiedMethods = pluginWorkerManager?.getWorker(pluginId)?.supportedMethods ?? [];
|
|
declared = resolveDeclaredSandboxCapabilities(resolvedDriver.driver);
|
|
try {
|
|
// Resolve the provider config to confirm it is readable. A provider
|
|
// whose config cannot be resolved is untrusted, so a resolution error
|
|
// fails closed on persistent process sessions below. The resolved
|
|
// value itself is no longer read for the narrowing decision.
|
|
await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
} catch {
|
|
configResolutionFailed = true;
|
|
}
|
|
} else {
|
|
const builtin = getBuiltinSandboxProvider(providerKey);
|
|
verifiedMethods = builtinSandboxProviderVerifiedMethods(builtin);
|
|
declared = resolveDeclaredSandboxCapabilities({
|
|
supportsReusableLeases: builtin?.supportsReusableLeases,
|
|
});
|
|
}
|
|
|
|
const narrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: input.lease.leasePolicy,
|
|
leaseMetadata: metadata,
|
|
configResolutionFailed,
|
|
});
|
|
|
|
return classifyEnvironmentCapabilities({
|
|
verifiedMethods,
|
|
declared,
|
|
narrowing,
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verify that the live plugin worker still advertises a reusable-lease
|
|
* lifecycle method before the runtime dispatches that RPC. The worker reports
|
|
* `supportedMethods` from its discovery list on every start. A worker restart
|
|
* can drop a lifecycle method a reusable lease was created under. The runtime
|
|
* must not dispatch a lifecycle RPC the live worker does not advertise. It
|
|
* fails closed when the worker is absent or the method is stale, so a lease
|
|
* that a worker can no longer clean up goes to the cleanup reaper instead of
|
|
* a doomed RPC.
|
|
*/
|
|
function pluginWorkerVerifiesLifecycleMethod(pluginId: string, method: string): boolean {
|
|
const advertised = pluginWorkerManager?.getWorker(pluginId)?.supportedMethods ?? [];
|
|
return advertised.includes(method);
|
|
}
|
|
|
|
async function releasePluginBackedSandboxLease(
|
|
input: EnvironmentDriverReleaseInput,
|
|
): Promise<EnvironmentLease | null> {
|
|
if (input.resourceDisposition === "stop_and_retain") {
|
|
throw new Error("Stop-only sandbox cleanup requires a durable stop intent.");
|
|
}
|
|
const metadata = input.lease.metadata ?? {};
|
|
const pluginId = readString(metadata.pluginId);
|
|
const providerKey = readString(metadata.provider);
|
|
|
|
let cleanupStatus: "success" | "failed" = "success";
|
|
let termination: ReturnType<typeof remoteTerminationReceipt>;
|
|
if (
|
|
pluginId &&
|
|
providerKey &&
|
|
pluginWorkerManager?.isRunning(pluginId) &&
|
|
pluginWorkerVerifiesLifecycleMethod(pluginId, "environmentReleaseLease")
|
|
) {
|
|
try {
|
|
const config = await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
const receipt = await runLeaseReleaseWithRunParent(input.lease.id, () =>
|
|
pluginWorkerManager.call(pluginId, "environmentReleaseLease", {
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: metadata,
|
|
...(input.cancelActiveWork ? { cancelActiveWork: true } : {}),
|
|
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig))),
|
|
);
|
|
termination = remoteTerminationReceipt(input.lease, receipt);
|
|
if (input.cancelActiveWork && !termination) cleanupStatus = "failed";
|
|
} catch {
|
|
cleanupStatus = "failed";
|
|
}
|
|
} else {
|
|
cleanupStatus = "failed";
|
|
}
|
|
|
|
// A failed release verification leaves the provider resource active. The
|
|
// cleanup reaper retries only `pending_cleanup` leases, so route a failed
|
|
// release into that retry flow. A `retain_on_failure` lease keeps the
|
|
// resource on purpose for reuse, so it stays `retained` and never enters the
|
|
// reaper, which would destroy the resource the retain policy wants to keep.
|
|
const retained =
|
|
input.lease.leasePolicy === "retain_on_failure" && input.status === "failed";
|
|
const releaseStatus = retained
|
|
? ("retained" as const)
|
|
: cleanupStatus === "failed"
|
|
? ("pending_cleanup" as const)
|
|
: input.status;
|
|
const failureReason =
|
|
input.status === "failed"
|
|
? "adapter_or_run_failure"
|
|
: cleanupStatus === "failed"
|
|
? "release_cleanup_failed"
|
|
: undefined;
|
|
return await environmentsSvc.releaseLease(input.lease.id, releaseStatus, {
|
|
failureReason,
|
|
cleanupStatus,
|
|
...(cleanupStatus === "success" && termination ? { remoteExecutionTermination: termination } : {}),
|
|
});
|
|
}
|
|
|
|
async function destroyReusableSandboxLease(input: {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
failureReason: string;
|
|
}): Promise<EnvironmentLease | null> {
|
|
let cleanupStatus: "success" | "failed" = "success";
|
|
let termination: ReturnType<typeof remoteTerminationReceipt>;
|
|
const metadata = input.lease.metadata ?? {};
|
|
|
|
try {
|
|
if (metadata.sandboxProviderPlugin) {
|
|
const pluginId = readString(metadata.pluginId);
|
|
const providerKey = readString(metadata.provider);
|
|
if (
|
|
!pluginId ||
|
|
!providerKey ||
|
|
!pluginWorkerManager?.isRunning(pluginId) ||
|
|
!pluginWorkerVerifiesLifecycleMethod(pluginId, "environmentDestroyLease")
|
|
) {
|
|
cleanupStatus = "failed";
|
|
} else {
|
|
const config = await resolvePluginSandboxRuntimeConfig({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
provider: providerKey,
|
|
});
|
|
const receipt = await runLeaseReleaseWithRunParent(input.lease.id, () =>
|
|
pluginWorkerManager.call(pluginId, "environmentDestroyLease", {
|
|
driverKey: providerKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: metadata,
|
|
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig))),
|
|
);
|
|
termination = remoteTerminationReceipt(input.lease, receipt);
|
|
}
|
|
} else {
|
|
const metadataConfig = sandboxConfigFromLeaseMetadata(input.lease);
|
|
const parsed = metadataConfig
|
|
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
|
id: input.environment.id,
|
|
driver: "sandbox",
|
|
config: metadataConfig as unknown as Record<string, unknown>,
|
|
})
|
|
: await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, input.environment);
|
|
if (parsed.driver !== "sandbox") {
|
|
cleanupStatus = "failed";
|
|
} else {
|
|
await destroySandboxProviderLease({
|
|
config: parsed.config,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
cleanupStatus = "failed";
|
|
}
|
|
|
|
return await environmentsSvc.releaseLease(
|
|
input.lease.id,
|
|
cleanupStatus === "success" ? "expired" : "pending_cleanup",
|
|
{
|
|
...(input.lease.status === "pending_cleanup" && typeof metadata.pendingCleanupAttemptId === "string"
|
|
? { expectedPendingCleanupAttemptId: metadata.pendingCleanupAttemptId } : {}),
|
|
failureReason: input.failureReason,
|
|
cleanupStatus,
|
|
...(cleanupStatus === "success" && termination ? { remoteExecutionTermination: termination } : {}),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseExpiresAt(value: string | null | undefined): Date | null {
|
|
if (!value) return null;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
/**
|
|
* Resolves the persisted lease expiry for a caller-requested deadline. It records
|
|
* ONLY a provider-attested expiry as evidence of provider enforcement. It never
|
|
* synthesizes the requested deadline onto the lease row: a database-only expiry
|
|
* does not stop a remote sandbox after a server crash, so it must not stand in for
|
|
* a provider-side bound. It returns the real provider expiry when the caller sets
|
|
* a deadline (the caller then verifies the expiry bounds the deadline and fails
|
|
* closed when it does not). It returns null when the caller sets a deadline and the
|
|
* provider grants no expiry. It returns the provider expiry unchanged when the
|
|
* caller requests no deadline, so all other callers keep the current behavior. It
|
|
* ignores an invalid requested deadline.
|
|
*/
|
|
function providerAttestedLeaseExpiry(
|
|
requestedExpiresAt: Date | null | undefined,
|
|
providerExpiresAt: Date | null | undefined,
|
|
): Date | null | undefined {
|
|
const requested =
|
|
requestedExpiresAt instanceof Date && !Number.isNaN(requestedExpiresAt.getTime())
|
|
? requestedExpiresAt
|
|
: null;
|
|
if (!requested) return providerExpiresAt;
|
|
return providerExpiresAt ?? null;
|
|
}
|
|
|
|
/**
|
|
* Converts a caller-requested deadline to the ISO 8601 string a provider acquire
|
|
* RPC carries. It returns undefined for an absent or invalid deadline, so a
|
|
* generic caller without a deadline sends no requested expiry and keeps the
|
|
* current provider behavior.
|
|
*/
|
|
function requestedExpiresAtParam(
|
|
requestedExpiresAt: Date | null | undefined,
|
|
): string | undefined {
|
|
return requestedExpiresAt instanceof Date && !Number.isNaN(requestedExpiresAt.getTime())
|
|
? requestedExpiresAt.toISOString()
|
|
: undefined;
|
|
}
|
|
|
|
function pluginDriverProviderKey(config: PluginEnvironmentConfig): string {
|
|
return `${config.pluginKey}:${config.driverKey}`;
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
// Keys the runtime stores in the lease metadata that are not part of the
|
|
// provider driver config. Some are host-internal control fields. `remoteCwd` is
|
|
// a per-lease runtime value. The host reads `remoteCwd` from the lease metadata
|
|
// directly, so the worker never needs it as config. Drop every key here before
|
|
// the runtime sends a config to a lifecycle RPC.
|
|
const INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS = new Set([
|
|
"driver",
|
|
"executionWorkspaceMode",
|
|
"pluginId",
|
|
"pluginKey",
|
|
"providerMetadata",
|
|
"remoteCwd",
|
|
"shellCommand",
|
|
"sandboxProviderPlugin",
|
|
"sandboxLeaseAcquisition",
|
|
"nativeHarnessBackup",
|
|
"nativeWorkspaceSync",
|
|
]);
|
|
|
|
// Drop the host-internal and per-lease runtime keys from a sandbox config
|
|
// record. The runtime stores these keys in the lease metadata and in some
|
|
// resolved configs, but the plugin worker must receive only the provider driver
|
|
// config. Use this on every config the runtime sends to a lifecycle RPC, so no
|
|
// host-internal field reaches the worker.
|
|
function dropInternalPluginSandboxConfigKeys(
|
|
config: Record<string, unknown> | null | undefined,
|
|
): Record<string, unknown> {
|
|
const sanitized: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(config ?? {})) {
|
|
if (INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS.has(key)) continue;
|
|
sanitized[key] = value;
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
function sandboxConfigForLeaseMetadata(config: SandboxEnvironmentConfig): Record<string, unknown> {
|
|
return { ...config };
|
|
}
|
|
|
|
function tryParseCurrentPluginConfig(environment: Environment): PluginEnvironmentConfig | null {
|
|
if (environment.driver !== "plugin") {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = parseEnvironmentDriverConfig(environment);
|
|
return parsed.driver === "plugin" ? parsed.config : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createPluginEnvironmentDriver(
|
|
db: Db,
|
|
workerManager: PluginWorkerManager,
|
|
): EnvironmentRuntimeDriver {
|
|
const environmentsSvc = environmentService(db);
|
|
const pluginRegistry = pluginRegistryService(db);
|
|
|
|
async function resolvePluginDriver(config: PluginEnvironmentConfig) {
|
|
const plugin = await pluginRegistry.getByKey(config.pluginKey);
|
|
if (!plugin || plugin.status !== "ready") {
|
|
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(config)}" is not ready.`);
|
|
}
|
|
const driver = plugin.manifestJson.environmentDrivers?.find(
|
|
(candidate) => candidate.driverKey === config.driverKey,
|
|
);
|
|
if (!driver) {
|
|
throw new Error(`Plugin "${config.pluginKey}" does not declare environment driver "${config.driverKey}".`);
|
|
}
|
|
if (!workerManager.isRunning(plugin.id)) {
|
|
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(config)}" has no running worker.`);
|
|
}
|
|
return { plugin, driver };
|
|
}
|
|
|
|
async function resolvePluginDriverForRelease(input: EnvironmentDriverReleaseInput) {
|
|
const metadata = input.lease.metadata ?? {};
|
|
const metadataPluginId = readString(metadata.pluginId);
|
|
const metadataPluginKey = readString(metadata.pluginKey);
|
|
const metadataDriverKey = readString(metadata.driverKey);
|
|
const currentConfig = tryParseCurrentPluginConfig(input.environment);
|
|
|
|
if (!metadataPluginId && !metadataPluginKey && !metadataDriverKey) {
|
|
if (!currentConfig) {
|
|
throw new Error(`Expected plugin environment config for driver "${input.environment.driver}".`);
|
|
}
|
|
const { plugin } = await resolvePluginDriver(currentConfig);
|
|
return {
|
|
plugin,
|
|
pluginKey: currentConfig.pluginKey,
|
|
driverKey: currentConfig.driverKey,
|
|
driverConfig: currentConfig.driverConfig,
|
|
};
|
|
}
|
|
|
|
const plugin = metadataPluginId
|
|
? await pluginRegistry.getById(metadataPluginId)
|
|
: metadataPluginKey
|
|
? await pluginRegistry.getByKey(metadataPluginKey)
|
|
: currentConfig
|
|
? await pluginRegistry.getByKey(currentConfig.pluginKey)
|
|
: null;
|
|
const driverKey = metadataDriverKey ?? currentConfig?.driverKey;
|
|
const pluginKey = metadataPluginKey ?? plugin?.pluginKey ?? currentConfig?.pluginKey ?? "unknown";
|
|
|
|
if (!driverKey) {
|
|
throw new Error(`Plugin environment driver "${pluginKey}:unknown" is missing a driver key.`);
|
|
}
|
|
|
|
if (!plugin || plugin.status !== "ready") {
|
|
throw new Error(`Plugin environment driver "${pluginKey}:${driverKey}" is not ready.`);
|
|
}
|
|
const declaredDriver = plugin.manifestJson.environmentDrivers?.find(
|
|
(candidate) => candidate.driverKey === driverKey,
|
|
);
|
|
if (!declaredDriver) {
|
|
throw new Error(`Plugin "${plugin.pluginKey}" does not declare environment driver "${driverKey}".`);
|
|
}
|
|
if (!workerManager.isRunning(plugin.id)) {
|
|
throw new Error(`Plugin environment driver "${plugin.pluginKey}:${driverKey}" has no running worker.`);
|
|
}
|
|
|
|
const currentConfigStillMatches =
|
|
currentConfig?.pluginKey === plugin.pluginKey && currentConfig.driverKey === driverKey;
|
|
|
|
return {
|
|
plugin,
|
|
pluginKey: plugin.pluginKey,
|
|
driverKey,
|
|
driverConfig: currentConfigStillMatches ? currentConfig.driverConfig : {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
driver: "plugin",
|
|
|
|
async acquireRunLease(input) {
|
|
const parsed = parseEnvironmentDriverConfig(input.environment);
|
|
if (parsed.driver !== "plugin") {
|
|
throw new Error(`Expected plugin environment config for driver "${input.environment.driver}".`);
|
|
}
|
|
const { plugin, driver } = await resolvePluginDriver(parsed.config);
|
|
const rpcTimeoutMs = resolvePluginSandboxRpcTimeoutMs(
|
|
parsed.config.driverConfig,
|
|
driver.defaultAcquireTimeoutMs,
|
|
);
|
|
const timeoutOverride: [number?] = rpcTimeoutMs === undefined ? [] : [rpcTimeoutMs];
|
|
const providerLease = await workerManager.call(plugin.id, "environmentAcquireLease", {
|
|
driverKey: parsed.config.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.issueId,
|
|
config: parsed.config.driverConfig,
|
|
runId: input.heartbeatRunId ?? randomUUID(),
|
|
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
|
agentId: input.agentId ?? undefined,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
|
|
adapterType: input.adapterType ?? undefined,
|
|
executionWorkspaceSettings: input.executionWorkspaceSettings,
|
|
// 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
|
|
? { requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt) }
|
|
: {}),
|
|
} as PluginEnvironmentAcquireLeaseParams, ...timeoutOverride);
|
|
|
|
return await environmentsSvc.acquireLease({
|
|
companyId: input.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
leasePolicy: "ephemeral",
|
|
provider: `plugin:${parsed.config.pluginKey}:${parsed.config.driverKey}`,
|
|
providerLeaseId: providerLease.providerLeaseId,
|
|
expiresAt: providerAttestedLeaseExpiry(input.requestedExpiresAt, parseExpiresAt(providerLease.expiresAt)),
|
|
metadata: {
|
|
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
providerMetadata: providerLease.metadata ?? {},
|
|
driver: input.environment.driver,
|
|
executionWorkspaceMode: input.executionWorkspaceMode,
|
|
pluginId: plugin.id,
|
|
pluginKey: parsed.config.pluginKey,
|
|
driverKey: parsed.config.driverKey,
|
|
},
|
|
});
|
|
},
|
|
|
|
async releaseRunLease(input) {
|
|
const { plugin, driverKey, driverConfig } = await resolvePluginDriverForRelease(input);
|
|
await workerManager.call(plugin.id, "environmentReleaseLease", {
|
|
driverKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: driverConfig,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: input.lease.metadata ?? undefined,
|
|
});
|
|
return await environmentsSvc.releaseLease(input.lease.id, input.status);
|
|
},
|
|
|
|
async resumeRunLease(input) {
|
|
if (!input.lease.providerLeaseId) {
|
|
throw new Error(`Plugin environment lease "${input.lease.id}" does not have a provider lease id to resume.`);
|
|
}
|
|
const { pluginKey, driverKey, driverConfig } = await resolvePluginDriverForRelease({
|
|
...input,
|
|
status: "released",
|
|
});
|
|
return await resumePluginEnvironmentLease({
|
|
db,
|
|
workerManager,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: {
|
|
pluginKey,
|
|
driverKey,
|
|
driverConfig,
|
|
},
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: input.lease.metadata ?? undefined,
|
|
});
|
|
},
|
|
|
|
async destroyRunLease(input) {
|
|
const { pluginKey, driverKey, driverConfig } = await resolvePluginDriverForRelease({
|
|
...input,
|
|
status: "failed",
|
|
});
|
|
await destroyPluginEnvironmentLease({
|
|
db,
|
|
workerManager,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: {
|
|
pluginKey,
|
|
driverKey,
|
|
driverConfig,
|
|
},
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
leaseMetadata: input.lease.metadata ?? undefined,
|
|
});
|
|
return await environmentsSvc.releaseLease(input.lease.id, "failed", {
|
|
failureReason: input.failureReason ?? "lease_destroyed",
|
|
});
|
|
},
|
|
|
|
async realizeWorkspace(input) {
|
|
const { plugin, pluginKey, driverKey, driverConfig } = await resolvePluginDriverForRelease({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
status: "released",
|
|
});
|
|
return await realizePluginEnvironmentWorkspace({
|
|
db,
|
|
workerManager,
|
|
pluginId: plugin.id,
|
|
config: {
|
|
pluginKey,
|
|
driverKey,
|
|
driverConfig,
|
|
},
|
|
params: {
|
|
driverKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: driverConfig,
|
|
lease: {
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: input.lease.metadata ?? undefined,
|
|
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
|
},
|
|
workspace: input.workspace,
|
|
},
|
|
});
|
|
},
|
|
|
|
async execute(input) {
|
|
const { plugin, pluginKey, driverKey, driverConfig } = await resolvePluginDriverForRelease({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
status: "released",
|
|
});
|
|
return await executePluginEnvironmentCommand({
|
|
db,
|
|
workerManager,
|
|
pluginId: plugin.id,
|
|
config: {
|
|
pluginKey,
|
|
driverKey,
|
|
driverConfig,
|
|
},
|
|
params: {
|
|
driverKey,
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
issueId: input.lease.issueId,
|
|
config: driverConfig,
|
|
lease: {
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: input.lease.metadata ?? undefined,
|
|
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
|
},
|
|
command: input.command,
|
|
args: input.args,
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
stdin: input.stdin,
|
|
timeoutMs: input.timeoutMs,
|
|
},
|
|
});
|
|
},
|
|
|
|
async resolveCapabilities(input) {
|
|
const metadata = input.lease.metadata ?? {};
|
|
const pluginId = readString(metadata.pluginId);
|
|
const driverKey = readString(metadata.driverKey);
|
|
const noCapabilities = () =>
|
|
classifyEnvironmentCapabilities({
|
|
verifiedMethods: [],
|
|
declared: null,
|
|
narrowing: null,
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.plugin.supportedCapabilities,
|
|
});
|
|
|
|
if (!pluginId || !driverKey) {
|
|
// The lease carries no plugin pin, so there is no exact plugin to read
|
|
// the declaration or the live worker methods from. Fail closed: resolve
|
|
// every capability false.
|
|
return noCapabilities();
|
|
}
|
|
|
|
// Read the declaration from the exact plugin that acquired the lease, not
|
|
// the plugin the environment's current config names. A plugin uninstall,
|
|
// a manifest change, or a config edit between the acquire and this call
|
|
// must not let a stale declaration or a different plugin's worker grant a
|
|
// capability this lease never verified.
|
|
const plugin = await pluginRegistry.getById(pluginId);
|
|
const declaredDriver =
|
|
plugin && plugin.status === "ready"
|
|
? plugin.manifestJson.environmentDrivers?.find((candidate) => candidate.driverKey === driverKey)
|
|
: undefined;
|
|
if (!plugin || plugin.status !== "ready" || !declaredDriver) {
|
|
return noCapabilities();
|
|
}
|
|
|
|
// Read the live worker method list fresh on every call. A worker restart
|
|
// can drop or add a method between the acquire and this call, so the
|
|
// runtime never trusts a cached method list for a capability grant.
|
|
const verifiedMethods = workerManager.getWorker(plugin.id)?.supportedMethods ?? [];
|
|
const declared = resolveDeclaredSandboxCapabilities(declaredDriver);
|
|
const narrowing = buildSandboxCapabilityNarrowing({
|
|
leasePolicy: input.lease.leasePolicy,
|
|
leaseMetadata: metadata,
|
|
});
|
|
|
|
return classifyEnvironmentCapabilities({
|
|
verifiedMethods,
|
|
declared,
|
|
narrowing,
|
|
supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.plugin.supportedCapabilities,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export function environmentRuntimeService(
|
|
db: Db,
|
|
options: {
|
|
drivers?: EnvironmentRuntimeDriver[];
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
pluginWorkerReadyTimeoutMs?: number;
|
|
pluginWorkerReadyPollMs?: number;
|
|
pendingCleanupWriteAttempts?: number;
|
|
pendingCleanupWriteBackoffMs?: number;
|
|
deferredOrphanCleanupBufferLimit?: number;
|
|
orphanCleanupSpool?: SandboxOrphanCleanupSpool;
|
|
orphanCleanupSpoolDir?: string;
|
|
} = {},
|
|
) {
|
|
const environmentsSvc = environmentService(db);
|
|
const drivers = new Map<string, EnvironmentRuntimeDriver>();
|
|
|
|
const defaultDrivers = [
|
|
createLocalEnvironmentDriver(db),
|
|
createSshEnvironmentDriver(db),
|
|
createSandboxEnvironmentDriver(db, {
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
pluginWorkerReadyTimeoutMs: options.pluginWorkerReadyTimeoutMs,
|
|
pluginWorkerReadyPollMs: options.pluginWorkerReadyPollMs,
|
|
pendingCleanupWriteAttempts: options.pendingCleanupWriteAttempts,
|
|
pendingCleanupWriteBackoffMs: options.pendingCleanupWriteBackoffMs,
|
|
deferredOrphanCleanupBufferLimit: options.deferredOrphanCleanupBufferLimit,
|
|
orphanCleanupSpool: options.orphanCleanupSpool,
|
|
orphanCleanupSpoolDir: options.orphanCleanupSpoolDir,
|
|
}),
|
|
...(options.pluginWorkerManager
|
|
? [createPluginEnvironmentDriver(db, options.pluginWorkerManager)]
|
|
: []),
|
|
];
|
|
|
|
for (const driver of options.drivers ?? defaultDrivers) {
|
|
drivers.set(driver.driver, driver);
|
|
}
|
|
|
|
function getDriver(driverKey: string): EnvironmentRuntimeDriver | null {
|
|
return drivers.get(driverKey) ?? null;
|
|
}
|
|
|
|
function requireDriver(environment: Pick<Environment, "driver">): EnvironmentRuntimeDriver {
|
|
const driver = getDriver(environment.driver);
|
|
if (!driver) {
|
|
throw new Error(
|
|
`Environment driver "${environment.driver}" is not registered in the environment runtime yet.`,
|
|
);
|
|
}
|
|
return driver;
|
|
}
|
|
|
|
function requireDriverKey(driverKey: string): EnvironmentRuntimeDriver {
|
|
const driver = getDriver(driverKey);
|
|
if (!driver) {
|
|
throw new Error(
|
|
`Environment driver "${driverKey}" is not registered in the environment runtime yet.`,
|
|
);
|
|
}
|
|
return driver;
|
|
}
|
|
|
|
return {
|
|
getDriver,
|
|
|
|
/**
|
|
* Read the sandbox duplex bridge kill switch for a new run. The host calls it
|
|
* per run before it selects the callback bridge transport. It reads the
|
|
* experimental instance setting `enableSandboxDuplexBridge` and maps it into
|
|
* the resolved bridge input. The default-off setting keeps the file bridge.
|
|
*/
|
|
async readSandboxDuplexBridgeInput(): Promise<ResolvedSandboxDuplexBridgeInput> {
|
|
const experimental = await instanceSettingsService(db).getExperimental();
|
|
return resolveSandboxDuplexBridgeInput(experimental);
|
|
},
|
|
|
|
/** Dispatch plugin-provided Runner execution, including resource preparation and task lifecycle. */
|
|
async task(input: { companyId: string; leaseId: string; operation: PluginEnvironmentTaskOperation }) {
|
|
if (!options.pluginWorkerManager) throw new Error("Plugin-provided Runner execution worker manager unavailable");
|
|
return executeEnvironmentTask(db, options.pluginWorkerManager, input);
|
|
},
|
|
|
|
async acquireRunLease(input: {
|
|
companyId: string;
|
|
environment: Environment;
|
|
issueId: string | null;
|
|
agentId?: string | null;
|
|
/** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */
|
|
heartbeatRunId: string | null;
|
|
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
|
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
|
/** The agent's adapter type for this run (mixed-harness environments). */
|
|
adapterType?: string | null;
|
|
/**
|
|
* Force applying the active custom-image template even for ad-hoc (no
|
|
* issue/run) invocations. Operator `Test` probes set this so the runtime
|
|
* lease uses the operator-prepared custom image.
|
|
*/
|
|
applyCustomImageTemplate?: boolean;
|
|
/**
|
|
* The latest time the acquired lease may stay active. The driver bounds
|
|
* the persisted lease expiry to this time. Null or undefined keeps the
|
|
* provider expiry only.
|
|
*/
|
|
requestedExpiresAt?: Date | null;
|
|
/**
|
|
* Re-check the environment company binding inside the lease insert
|
|
* transaction. The login acquire paths set this to reject a foreign-company
|
|
* environment with the 403 `environment_company_mismatch`.
|
|
*/
|
|
assertCompanyBinding?: boolean;
|
|
}): Promise<EnvironmentRuntimeLeaseRecord> {
|
|
if (input.environment.status !== "active") {
|
|
throw new Error(`Environment "${input.environment.name}" is not active.`);
|
|
}
|
|
|
|
const leaseContext = buildEnvironmentLeaseContext({
|
|
persistedExecutionWorkspace: input.persistedExecutionWorkspace,
|
|
});
|
|
const driver = requireDriver(input.environment);
|
|
const lease = await driver.acquireRunLease({
|
|
companyId: input.companyId,
|
|
environment: input.environment,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId ?? null,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
executionWorkspaceId: leaseContext.executionWorkspaceId,
|
|
executionWorkspaceMode: leaseContext.executionWorkspaceMode,
|
|
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
|
adapterType: input.adapterType ?? null,
|
|
applyCustomImageTemplate: input.applyCustomImageTemplate ?? false,
|
|
requestedExpiresAt: input.requestedExpiresAt ?? null,
|
|
assertCompanyBinding: input.assertCompanyBinding,
|
|
});
|
|
|
|
return {
|
|
environment: input.environment,
|
|
lease,
|
|
leaseContext,
|
|
};
|
|
},
|
|
|
|
async releaseRunLeases(
|
|
heartbeatRunId: string,
|
|
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed"> = "released",
|
|
onLeaseReleaseError?: (leaseId: string, error: unknown) => void,
|
|
providerResourceDisposition?: ProviderResourceDisposition,
|
|
cancelActiveWork?: boolean,
|
|
): Promise<EnvironmentRuntimeLeaseRecord[]> {
|
|
const leaseRows = await db
|
|
.select()
|
|
.from(environmentLeases)
|
|
.where(
|
|
and(
|
|
eq(environmentLeases.heartbeatRunId, heartbeatRunId),
|
|
or(eq(environmentLeases.status, "active"), and(eq(environmentLeases.status, "pending_cleanup"),
|
|
sql`(${environmentLeases.metadata} ? 'nativeWorkspaceExportResume' or ${environmentLeases.metadata} ? 'sandboxStopAndRetain')`)),
|
|
),
|
|
);
|
|
if (leaseRows.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Release each lease in its own try/catch. One driver error must not stop
|
|
// the release of the later leases. The caller records each lease-specific
|
|
// error through `onLeaseReleaseError` for its log path. Keep the order
|
|
// serial.
|
|
const released: EnvironmentRuntimeLeaseRecord[] = [];
|
|
for (const leaseRow of leaseRows) {
|
|
try {
|
|
const environment = leaseRow.environmentId
|
|
? await environmentsSvc.getById(leaseRow.environmentId)
|
|
: null;
|
|
let leaseSnapshot = toEnvironmentLeaseSnapshot(leaseRow);
|
|
if (hasNativeWorkspaceExportResume(leaseSnapshot) && providerResourceDisposition === "destroy") {
|
|
const completed = await releaseCompletedNativeWorkspaceExportRetention(db, leaseSnapshot);
|
|
if (completed) leaseSnapshot = toEnvironmentLeaseSnapshot(completed);
|
|
}
|
|
if (providerResourceDisposition === "stop_and_retain" && !hasStopOnlyCleanup(leaseSnapshot)
|
|
&& getLeaseDriverKey(leaseSnapshot, environment) === "sandbox") {
|
|
const prepared = await prepareSandboxStopAndRetain(db, leaseSnapshot);
|
|
if (!prepared) continue;
|
|
leaseSnapshot = toEnvironmentLeaseSnapshot(prepared);
|
|
}
|
|
if (hasStopOnlyCleanup(leaseSnapshot)) {
|
|
const intentKey = stopOnlyCleanupKey(leaseSnapshot);
|
|
const attemptId = randomUUID();
|
|
const [claimed] = await db.update(environmentLeases).set({
|
|
status: "pending_cleanup", cleanupStatus: "failed", releasedAt: new Date(),
|
|
metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ pendingCleanupAttemptId: attemptId,
|
|
pendingCleanupInFlight: true, pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000 })}::jsonb`,
|
|
}).where(and(eq(environmentLeases.id, leaseRow.id), eq(environmentLeases.heartbeatRunId, heartbeatRunId),
|
|
inArray(environmentLeases.status, ["active", "pending_cleanup"]),
|
|
sql`coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') = 'false'`,
|
|
sql`${environmentLeases.metadata}->${intentKey} = ${JSON.stringify(leaseSnapshot.metadata?.[intentKey])}::jsonb`,
|
|
)).returning();
|
|
if (!claimed) continue;
|
|
const snapshot = toEnvironmentLeaseSnapshot(claimed);
|
|
try {
|
|
const driver = requireDriverKey(getLeaseDriverKey(snapshot, environment));
|
|
if (!driver.retryPendingSandboxTeardown) throw new Error("Workspace export recovery requires stop-only cleanup.");
|
|
const receipt = await driver.retryPendingSandboxTeardown({ environment, lease: snapshot });
|
|
const lease = await settleStopOnlyCleanup(db, snapshot, { attemptId, receipt });
|
|
if (lease && environment) released.push({ environment, lease: toEnvironmentLeaseSnapshot(lease),
|
|
leaseContext: { executionWorkspaceId: lease.executionWorkspaceId, executionWorkspaceMode: null } });
|
|
} catch (error) {
|
|
await settleStopOnlyCleanup(db, snapshot, { attemptId });
|
|
throw error;
|
|
}
|
|
continue;
|
|
}
|
|
if (!environment) continue;
|
|
if (
|
|
providerResourceDisposition === "keep_running" &&
|
|
leaseSnapshot.leasePolicy === "reuse_by_environment"
|
|
) {
|
|
const lease = await environmentsSvc.releaseLease(
|
|
leaseRow.id,
|
|
"retained",
|
|
{ cleanupStatus: "success" },
|
|
);
|
|
if (lease) {
|
|
released.push({
|
|
environment,
|
|
lease,
|
|
leaseContext: {
|
|
executionWorkspaceId: lease.executionWorkspaceId,
|
|
executionWorkspaceMode:
|
|
(lease.metadata?.executionWorkspaceMode as ExecutionWorkspace["mode"] | null | undefined) ?? null,
|
|
},
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment));
|
|
if (
|
|
providerResourceDisposition === "keep_running" &&
|
|
leaseSnapshot.leasePolicy !== "reuse_by_environment"
|
|
) {
|
|
throw new Error(
|
|
`Cannot keep non-reusable environment lease "${leaseSnapshot.id}" running.`,
|
|
);
|
|
}
|
|
if (
|
|
providerResourceDisposition === "destroy" &&
|
|
isRecord(leaseSnapshot.metadata?.reusableSandboxLease) &&
|
|
leaseSnapshot.metadata.reusableSandboxLease.adapterType ===
|
|
"paperclip_runner" &&
|
|
leaseSnapshot.metadata?.sandboxLeaseAcquisition &&
|
|
(!leaseSnapshot.providerLeaseId ||
|
|
!verifyNativeHarnessBackupStamp(
|
|
leaseSnapshot.metadata.nativeHarnessBackup,
|
|
leaseSnapshot.providerLeaseId,
|
|
))
|
|
) {
|
|
throw new RunnerHarnessBackupUnavailableError(
|
|
leaseSnapshot.providerLeaseId ?? leaseSnapshot.id,
|
|
);
|
|
}
|
|
const lease = providerResourceDisposition === "destroy" && driver?.destroyRunLease
|
|
? await driver.destroyRunLease({
|
|
environment,
|
|
lease: leaseSnapshot,
|
|
failureReason: "paperclip_runner_destroy_after_turn",
|
|
})
|
|
: driver
|
|
? await driver.releaseRunLease({
|
|
...(cancelActiveWork ? { cancelActiveWork: true } : {}),
|
|
...(providerResourceDisposition === "stop_and_retain" ? { resourceDisposition: "stop_and_retain" as const } : {}),
|
|
environment,
|
|
lease: leaseSnapshot,
|
|
// A stopped reusable provider resource must remain eligible
|
|
// for exact-lease resume independently of turn outcome.
|
|
status:
|
|
providerResourceDisposition === "stop_and_retain" &&
|
|
leaseSnapshot.leasePolicy === "reuse_by_environment"
|
|
? "released"
|
|
: status,
|
|
})
|
|
: await environmentsSvc.releaseLease(
|
|
leaseRow.id,
|
|
providerResourceDisposition === "destroy" ? "expired" : status,
|
|
);
|
|
if (!lease) continue;
|
|
|
|
released.push({
|
|
environment,
|
|
lease,
|
|
leaseContext: {
|
|
executionWorkspaceId: lease.executionWorkspaceId,
|
|
executionWorkspaceMode:
|
|
(lease.metadata?.executionWorkspaceMode as ExecutionWorkspace["mode"] | null | undefined) ?? null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
onLeaseReleaseError?.(leaseRow.id, error);
|
|
}
|
|
}
|
|
|
|
return released;
|
|
},
|
|
|
|
// Tear an orphan ephemeral sandbox down from its recorded provider config.
|
|
// A failed acquire records the orphan as a `pending_cleanup` lease. The row
|
|
// keeps the provider, the provider lease id, and the sandbox config, so this
|
|
// teardown runs without the environment row and accepts a null environment.
|
|
// The teardown resolves the recorded secret refs through the durable orphan
|
|
// record, so a deleted or foreign-bound environment never strands it. The
|
|
// caller owns the lease release; this dispatcher only runs the provider
|
|
// teardown and throws when the driver teardown throws.
|
|
async retryPendingSandboxTeardown(input: {
|
|
environment: Environment | null;
|
|
lease: EnvironmentLease;
|
|
}): Promise<unknown> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.retryPendingSandboxTeardown) {
|
|
throw new Error(
|
|
`Environment driver "${driver.driver}" does not support orphan sandbox teardown.`,
|
|
);
|
|
}
|
|
return await driver.retryPendingSandboxTeardown(input);
|
|
},
|
|
|
|
// Report whether the provider worker can run an orphan teardown now. The
|
|
// cleanup sweep calls this before it claims a finite retry attempt, so a
|
|
// briefly-down plugin worker never burns an attempt. This dispatcher never
|
|
// throws: an unregistered driver, or a driver with no probe, reports ready,
|
|
// so the teardown still runs and its own failure counts toward the cap.
|
|
async isPendingCleanupWorkerReady(input: {
|
|
environment: Environment | null;
|
|
lease: EnvironmentLease;
|
|
}): Promise<boolean> {
|
|
const driver = getDriver(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver?.isPendingCleanupWorkerReady) return true;
|
|
return driver.isPendingCleanupWorkerReady(input);
|
|
},
|
|
|
|
// Flush the in-process orphan-cleanup buffers of every driver that keeps one.
|
|
// The cleanup sweep calls this each tick, so a buffered orphan lands a durable
|
|
// `pending_cleanup` row once the database recovers, and the same sweep tears
|
|
// it down. The dispatcher sums the recovered and pending counts across all
|
|
// drivers. A driver with no buffer contributes nothing.
|
|
async flushDeferredOrphanCleanups(): Promise<{ recovered: number; pending: number }> {
|
|
let recovered = 0;
|
|
let pending = 0;
|
|
for (const driver of drivers.values()) {
|
|
if (!driver.flushDeferredOrphanCleanups) continue;
|
|
const result = await driver.flushDeferredOrphanCleanups();
|
|
recovered += result.recovered;
|
|
pending += result.pending;
|
|
}
|
|
return { recovered, pending };
|
|
},
|
|
|
|
async destroyReusableSandboxLeases(input: {
|
|
companyId: string;
|
|
issueId?: string | null;
|
|
executionWorkspaceId?: string | null;
|
|
failureReason?: string;
|
|
}): Promise<EnvironmentRuntimeLeaseRecord[]> {
|
|
const scopeConditions = [
|
|
input.issueId ? eq(environmentLeases.issueId, input.issueId) : undefined,
|
|
input.executionWorkspaceId ? eq(environmentLeases.executionWorkspaceId, input.executionWorkspaceId) : undefined,
|
|
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
|
|
if (scopeConditions.length === 0) return [];
|
|
|
|
const leaseRows = await db
|
|
.select()
|
|
.from(environmentLeases)
|
|
.where(
|
|
and(
|
|
eq(environmentLeases.companyId, input.companyId),
|
|
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
|
inArray(environmentLeases.status, ["active", "released", "retained", "pending_cleanup"]),
|
|
...scopeConditions,
|
|
),
|
|
);
|
|
|
|
const holdingRunIds = leaseRows
|
|
.map((row) => row.heartbeatRunId)
|
|
.filter((runId): runId is string => Boolean(runId));
|
|
const liveRunIds = new Set<string>();
|
|
if (holdingRunIds.length > 0) {
|
|
const liveRuns = await db
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
inArray(heartbeatRuns.id, holdingRunIds),
|
|
inArray(heartbeatRuns.status, [
|
|
"queued",
|
|
"scheduled_retry",
|
|
"running",
|
|
]),
|
|
),
|
|
);
|
|
for (const liveRun of liveRuns) liveRunIds.add(liveRun.id);
|
|
}
|
|
|
|
const destroyed: EnvironmentRuntimeLeaseRecord[] = [];
|
|
for (const leaseRow of leaseRows) {
|
|
// An issue may become terminal inside its provider turn. Do not tear
|
|
// down the sandbox while that run is still exporting its workspace or
|
|
// polling the callback bridge. The heartbeat finalizer observes the
|
|
// terminal issue and destroys the resource after those boundaries.
|
|
if (
|
|
leaseRow.heartbeatRunId &&
|
|
liveRunIds.has(leaseRow.heartbeatRunId)
|
|
) {
|
|
continue;
|
|
}
|
|
const environment = leaseRow.environmentId
|
|
? await environmentsSvc.getById(leaseRow.environmentId)
|
|
: null;
|
|
if (!environment) continue;
|
|
const leaseSnapshot = toEnvironmentLeaseSnapshot(leaseRow);
|
|
const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment));
|
|
const lease = driver?.destroyRunLease
|
|
? await driver.destroyRunLease({
|
|
environment,
|
|
lease: leaseSnapshot,
|
|
failureReason: input.failureReason ?? "reusable_lease_destroyed",
|
|
})
|
|
: await environmentsSvc.releaseLease(leaseSnapshot.id, "pending_cleanup", {
|
|
failureReason: input.failureReason ?? "reusable_lease_destroyed",
|
|
cleanupStatus: "failed",
|
|
});
|
|
if (!lease) continue;
|
|
destroyed.push({
|
|
environment,
|
|
lease,
|
|
leaseContext: {
|
|
executionWorkspaceId: lease.executionWorkspaceId,
|
|
executionWorkspaceMode:
|
|
(lease.metadata?.executionWorkspaceMode as ExecutionWorkspace["mode"] | null | undefined) ?? null,
|
|
},
|
|
});
|
|
}
|
|
return destroyed;
|
|
},
|
|
|
|
/**
|
|
* Destroy every reusable sandbox lease still owned by one environment, so a
|
|
* consented environment delete can proceed. This must run while the
|
|
* environment row still exists: the driver resolves provider credentials
|
|
* from the environment config, and after the delete the normal destroy path
|
|
* has no context left. A per-lease failure is contained — the driver routes
|
|
* a failed teardown to `pending_cleanup` for the sweep, and an unexpected
|
|
* throw leaves the lease in place — so the caller re-checks the blast
|
|
* radius instead of trusting these counts for the delete decision.
|
|
*/
|
|
async destroyReusableSandboxLeasesForEnvironment(input: {
|
|
environmentId: string;
|
|
failureReason?: string;
|
|
}): Promise<{ destroyed: number; failed: number; skippedLiveRun: number }> {
|
|
const environment = await environmentsSvc.getById(input.environmentId);
|
|
if (!environment) return { destroyed: 0, failed: 0, skippedLiveRun: 0 };
|
|
const leaseRows = await db
|
|
.select()
|
|
.from(environmentLeases)
|
|
.where(
|
|
and(
|
|
eq(environmentLeases.environmentId, input.environmentId),
|
|
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
|
inArray(environmentLeases.status, ["active", "released", "retained"]),
|
|
),
|
|
);
|
|
|
|
// A lease whose holding run is still in flight keeps its sandbox: the
|
|
// consented delete must not tear a live run's environment out from under
|
|
// it. The skipped lease keeps blocking the delete, so the caller's
|
|
// blast-radius re-check rejects and the operator retries after the run
|
|
// finishes. A lease pointing at a finished run — or at no run — is a
|
|
// stale reservation and destroys normally.
|
|
const holdingRunIds = leaseRows
|
|
.map((row) => row.heartbeatRunId)
|
|
.filter((runId): runId is string => Boolean(runId));
|
|
const liveRunIds = new Set<string>();
|
|
if (holdingRunIds.length > 0) {
|
|
const liveRuns = await db
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
inArray(heartbeatRuns.id, holdingRunIds),
|
|
inArray(heartbeatRuns.status, ["queued", "scheduled_retry", "running"]),
|
|
),
|
|
);
|
|
for (const run of liveRuns) liveRunIds.add(run.id);
|
|
}
|
|
|
|
let destroyed = 0;
|
|
let failed = 0;
|
|
let skippedLiveRun = 0;
|
|
const failureReason = input.failureReason ?? "environment_delete_requested";
|
|
const now = new Date();
|
|
for (const leaseRow of leaseRows) {
|
|
if (leaseRow.heartbeatRunId && liveRunIds.has(leaseRow.heartbeatRunId)) {
|
|
skippedLiveRun += 1;
|
|
continue;
|
|
}
|
|
// Claim the row BEFORE the provider call, mirroring the inline-teardown
|
|
// invariant used elsewhere in this file: no provider destroy without a
|
|
// durable `pending_cleanup` reference already on disk. The claim is one
|
|
// conditional UPDATE, so it is the fence against a racing resume: a
|
|
// resume that re-activates the lease first makes the status predicate
|
|
// (or the run-liveness predicate) fail and the claim loses — the live
|
|
// run keeps its sandbox. A claim that wins parks the lease where the
|
|
// cleanup sweep owns it, so a crash or thrown destroy after this point
|
|
// is recovered by the sweep's idempotent teardown, and a double write
|
|
// failure cannot strand the lease in a reusable status.
|
|
const claimedRow = await db
|
|
.update(environmentLeases)
|
|
.set({
|
|
status: "pending_cleanup",
|
|
failureReason,
|
|
cleanupStatus: "failed",
|
|
releasedAt: now,
|
|
lastUsedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(environmentLeases.id, leaseRow.id),
|
|
inArray(environmentLeases.status, ["active", "released", "retained"]),
|
|
sql`NOT EXISTS (
|
|
SELECT 1 FROM ${heartbeatRuns}
|
|
WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId}
|
|
AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running')
|
|
)`,
|
|
),
|
|
)
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!claimedRow) {
|
|
// Lost to a racing resume or a concurrent terminal transition — the
|
|
// lease is no longer ours to destroy.
|
|
skippedLiveRun += 1;
|
|
continue;
|
|
}
|
|
const leaseSnapshot = toEnvironmentLeaseSnapshot(claimedRow);
|
|
try {
|
|
const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment));
|
|
if (!driver?.destroyRunLease) {
|
|
// No driver available: the claim already parked the lease for the
|
|
// sweep, which retries once the driver's plugin is back.
|
|
failed += 1;
|
|
continue;
|
|
}
|
|
const lease = await driver.destroyRunLease({
|
|
environment,
|
|
lease: leaseSnapshot,
|
|
failureReason,
|
|
});
|
|
if (lease && lease.status !== "pending_cleanup") destroyed += 1;
|
|
else failed += 1;
|
|
} catch {
|
|
// The claim above already parked the lease in `pending_cleanup`, so
|
|
// the sweep owns the retry; its teardown is idempotent, so a destroy
|
|
// that reached the provider before the throw resolves as success.
|
|
failed += 1;
|
|
}
|
|
}
|
|
return { destroyed, failed, skippedLiveRun };
|
|
},
|
|
|
|
async resumeRunLease(input: EnvironmentDriverLeaseInput): Promise<PluginEnvironmentLease | EnvironmentLease | null> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.resumeRunLease) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support lease resume.`);
|
|
}
|
|
return await driver.resumeRunLease(input);
|
|
},
|
|
|
|
async destroyRunLease(input: EnvironmentDriverLeaseInput): Promise<EnvironmentLease | null> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.destroyRunLease) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support lease destroy.`);
|
|
}
|
|
return await driver.destroyRunLease(input);
|
|
},
|
|
|
|
async realizeWorkspace(
|
|
input: EnvironmentDriverRealizeWorkspaceInput,
|
|
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.realizeWorkspace) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support workspace realization.`);
|
|
}
|
|
return await driver.realizeWorkspace(input);
|
|
},
|
|
|
|
async execute(input: EnvironmentDriverExecuteInput): Promise<PluginEnvironmentExecuteResult> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.execute) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support command execution.`);
|
|
}
|
|
return await driver.execute(input);
|
|
},
|
|
|
|
supportsSync(input: EnvironmentDriverLeaseInput): boolean {
|
|
const driver = getDriver(getLeaseDriverKey(input.lease, input.environment));
|
|
return driver?.supportsSync?.(input) ?? false;
|
|
},
|
|
|
|
/**
|
|
* Resolve the general per-lease capability snapshot through the driver's
|
|
* {@link EnvironmentRuntimeDriver.resolveCapabilities}. Every registered
|
|
* driver implements this method, so it returns a full snapshot for any
|
|
* registered driver. It returns `null` only when the lease's driver is
|
|
* not registered — never as a stand-in for "every capability denied".
|
|
*/
|
|
async resolveCapabilities(
|
|
input: EnvironmentDriverLeaseInput,
|
|
): Promise<EffectiveExecutionCapabilities | null> {
|
|
const driver = getDriver(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver) return null;
|
|
return await driver.resolveCapabilities(input);
|
|
},
|
|
|
|
async syncIn(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.syncIn) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support native file sync.`);
|
|
}
|
|
return await driver.syncIn(input);
|
|
},
|
|
|
|
async syncOut(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.syncOut) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support native file sync.`);
|
|
}
|
|
return await driver.syncOut(input);
|
|
},
|
|
|
|
async openDuplexChannel(
|
|
input: EnvironmentDriverOpenDuplexChannelInput,
|
|
): Promise<CommandManagedDuplexChannel> {
|
|
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
|
if (!driver.openDuplexChannel) {
|
|
throw new Error(`Environment driver "${driver.driver}" does not support duplex channels.`);
|
|
}
|
|
// Centralize the duplex channel authorization here. Resolve the exact lease
|
|
// capability snapshot through the general resolver and refuse unless the
|
|
// effective snapshot grants the opt-in `duplexCommandStream` capability.
|
|
// This gate runs before the driver call, so an unauthorized lease never
|
|
// reaches the worker. The execution-target member gate stays as defense
|
|
// in depth. A driver that cannot resolve the snapshot fails closed with
|
|
// the fixed refusal.
|
|
const effective = await driver.resolveCapabilities(input);
|
|
if (effective.duplexCommandStream !== true) {
|
|
throw new Error(DUPLEX_CHANNEL_CAPABILITY_DENIED);
|
|
}
|
|
return await driver.openDuplexChannel(input);
|
|
},
|
|
|
|
async getRunnerIngressEndpoint(
|
|
input: EnvironmentDriverRunnerIngressInput,
|
|
): Promise<RunnerIngressEndpoint> {
|
|
const driver = requireDriverKey(
|
|
getLeaseDriverKey(input.lease, input.environment),
|
|
);
|
|
const effective = await driver.resolveCapabilities(input);
|
|
if (effective.runnerWebSocketIngress !== true) {
|
|
throw new Error(
|
|
"Sandbox lease does not grant runner WebSocket ingress.",
|
|
);
|
|
}
|
|
if (!driver.getRunnerIngressEndpoint) {
|
|
throw new Error(
|
|
`Environment driver "${driver.driver}" does not support runner ingress.`,
|
|
);
|
|
}
|
|
return await driver.getRunnerIngressEndpoint(input);
|
|
},
|
|
};
|
|
}
|
|
|
|
export type EnvironmentRuntimeService = ReturnType<typeof environmentRuntimeService>;
|