diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c36b450dba..10fae7aa6e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -918,8 +918,6 @@ export type { WorkspaceRuntimeDesiredState, WorkspaceRealizationRecord, WorkspaceRealizationRequest, - WorkspaceRealizationSyncStrategy, - WorkspaceRealizationTransport, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, SharedWorkspaceConcurrency, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5eb53a963c..4fa150445c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -391,8 +391,6 @@ export type { WorkspaceRuntimeDesiredState, WorkspaceRealizationRecord, WorkspaceRealizationRequest, - WorkspaceRealizationSyncStrategy, - WorkspaceRealizationTransport, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, SharedWorkspaceConcurrency, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index a36fbaf162..74ba98ed8b 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -325,7 +325,6 @@ export interface WorkspaceRuntimeService { updatedAt: Date; } -export type WorkspaceRealizationTransport = "local" | "ssh" | "sandbox" | "plugin"; export type WorkspaceRealizationMode = "copy" | "in_place"; export interface WorkspaceRealizationPathAlias { @@ -333,12 +332,6 @@ export interface WorkspaceRealizationPathAlias { target: string; } -export type WorkspaceRealizationSyncStrategy = - | "none" - | "ssh_git_import_export" - | "sandbox_archive_upload_download" - | "provider_defined"; - export interface WorkspaceRealizationRequest { version: 1; adapterType: string; @@ -387,7 +380,6 @@ export interface WorkspaceRealizationRecord { authoritativeRoot: string; pathAliases: WorkspaceRealizationPathAlias[]; outboundRestorePaths: string[]; - transport: WorkspaceRealizationTransport; provider: string | null; environmentId: string; leaseId: string; @@ -424,11 +416,6 @@ export interface WorkspaceRealizationRecord { username?: string | null; sandboxId?: string | null; }; - sync: { - strategy: WorkspaceRealizationSyncStrategy; - prepare: string; - syncBack: string | null; - }; bootstrap: { command: string | null; }; diff --git a/server/src/__tests__/sandbox-capability-contract.test.ts b/server/src/__tests__/environment-capability-contract.test.ts similarity index 70% rename from server/src/__tests__/sandbox-capability-contract.test.ts rename to server/src/__tests__/environment-capability-contract.test.ts index 1c9c933620..db76e52419 100644 --- a/server/src/__tests__/sandbox-capability-contract.test.ts +++ b/server/src/__tests__/environment-capability-contract.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import { + ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT, SANDBOX_CAPABILITY_KEYS, buildSandboxCapabilityNarrowing, builtinSandboxProviderVerifiedMethods, - resolveEffectiveSandboxCapabilities, + classifyEnvironmentCapabilities, } from "../services/environment-runtime.js"; // The worker verbs a fully-capable plug-in provider advertises. @@ -17,12 +18,12 @@ const ALL_PLUGIN_METHODS = [ "environmentSyncOut", ]; -describe("sandbox capability contract normalizer", () => { +describe("environment capability contract normalizer", () => { it("test_absent_declaration_defers_to_worker_supported_methods_discovery", () => { // No declaration at all. The effective set must fall back to what the worker // verified, so a third-party provider that implements the sync hooks keeps // native sync without declaring it. - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentSyncIn", "environmentSyncOut"], declared: null, }); @@ -41,7 +42,7 @@ describe("sandbox capability contract normalizer", () => { independentControlCommands: false, nativeSyncIn: true, }; - const effective = resolveEffectiveSandboxCapabilities({ verifiedMethods, declared }); + const effective = classifyEnvironmentCapabilities({ verifiedMethods, declared }); // Verified + declared true. expect(effective.persistentProcessSessions).toBe(true); @@ -53,7 +54,7 @@ describe("sandbox capability contract normalizer", () => { // Every effective capability must be a subset of the verified set and the // declaration: an effective `true` never appears where the worker did not // verify or the declaration set `false`. - const verifiedOnly = resolveEffectiveSandboxCapabilities({ verifiedMethods }); + const verifiedOnly = classifyEnvironmentCapabilities({ verifiedMethods }); for (const key of SANDBOX_CAPABILITY_KEYS) { if (effective[key]) { expect(verifiedOnly[key]).toBe(true); @@ -67,7 +68,7 @@ describe("sandbox capability contract normalizer", () => { leasePolicy: "ephemeral", leaseMetadata: { backend: "job" }, }); - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods: ALL_PLUGIN_METHODS, declared: { nativeSyncIn: true, nativeSyncOut: true }, narrowing, @@ -101,7 +102,7 @@ describe("sandbox capability contract normalizer", () => { // A normal lease adds no persistent-session narrowing. expect(narrowing.persistentProcessSessions).toBeUndefined(); - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods, declared, narrowing, @@ -124,7 +125,7 @@ describe("sandbox capability contract normalizer", () => { }); expect(narrowing.persistentProcessSessions).toBe(false); - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods, declared, narrowing, @@ -150,13 +151,13 @@ describe("sandbox capability contract normalizer", () => { supportsReusableLeases: true, execute: () => undefined, }); - const builtinEffective = resolveEffectiveSandboxCapabilities({ + const builtinEffective = classifyEnvironmentCapabilities({ verifiedMethods: builtinMethods, declared, }); // A plug-in provider that advertises the equivalent verbs. - const pluginEffective = resolveEffectiveSandboxCapabilities({ + const pluginEffective = classifyEnvironmentCapabilities({ verifiedMethods: [ "environmentResumeLease", "environmentReleaseLease", @@ -175,7 +176,7 @@ describe("sandbox capability contract normalizer", () => { expect(builtinEffective.nativeSyncIn).toBe(false); // A built-in provider without an execute method verifies no exec capability. - const noExec = resolveEffectiveSandboxCapabilities({ + const noExec = classifyEnvironmentCapabilities({ verifiedMethods: builtinSandboxProviderVerifiedMethods({ supportsReusableLeases: false }), declared: { persistentProcessSessions: true }, }); @@ -186,7 +187,7 @@ describe("sandbox capability contract normalizer", () => { // One case per capability: the declaration sets the flag `true`, the worker // lacks a prerequisite verb, and the effective value stays `false`. for (const key of SANDBOX_CAPABILITY_KEYS) { - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods: [], declared: { [key]: true }, }); @@ -195,7 +196,7 @@ describe("sandbox capability contract normalizer", () => { // A single missing prerequisite verb is enough: reusable leases needs // resume, release, and destroy, so resume alone does not grant it. - const resumeOnly = resolveEffectiveSandboxCapabilities({ + const resumeOnly = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentResumeLease"], declared: { reusableLeases: true }, }); @@ -206,14 +207,14 @@ describe("sandbox capability contract normalizer", () => { // A provider that verifies resume and release but not destroy is not // eligible for reusable leases. The reuse path destroys a stale lease when a // resume fails, so a provider without destroy support would strand the lease. - const resumeAndReleaseOnly = resolveEffectiveSandboxCapabilities({ + const resumeAndReleaseOnly = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentResumeLease", "environmentReleaseLease"], declared: { reusableLeases: true }, }); expect(resumeAndReleaseOnly.reusableLeases).toBe(false); // Adding the destroy verb makes the same provider eligible. - const allReuseVerbs = resolveEffectiveSandboxCapabilities({ + const allReuseVerbs = classifyEnvironmentCapabilities({ verifiedMethods: [ "environmentResumeLease", "environmentReleaseLease", @@ -231,7 +232,7 @@ describe("sandbox capability contract normalizer", () => { // true, but `incrementalSessionOutput` must stay false because the provider // did not declare the opt-in behavior. The session-output streaming gate // reads `incrementalSessionOutput`, so this provider keeps the poll path. - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentExecute"], declared: { persistentProcessSessions: true, @@ -249,7 +250,7 @@ describe("sandbox capability contract normalizer", () => { // An absent declaration denies the opt-in capability even when the worker // verifies the prerequisite verb. This differs from a worker-property // capability, which defers to the verified baseline. - const undeclared = resolveEffectiveSandboxCapabilities({ + const undeclared = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentExecute"], declared: null, }); @@ -257,14 +258,14 @@ describe("sandbox capability contract normalizer", () => { // A provider that declares the capability and verifies the prerequisite gets // the streaming path. - const declared = resolveEffectiveSandboxCapabilities({ + const declared = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentExecute"], declared: { incrementalSessionOutput: true }, }); expect(declared.incrementalSessionOutput).toBe(true); // A declaration never grants the capability without the verified verb. - const declaredButUnverified = resolveEffectiveSandboxCapabilities({ + const declaredButUnverified = classifyEnvironmentCapabilities({ verifiedMethods: [], declared: { incrementalSessionOutput: true }, }); @@ -282,7 +283,7 @@ describe("sandbox capability contract normalizer", () => { }); expect(narrowing.incrementalSessionOutput).toBe(false); - const effective = resolveEffectiveSandboxCapabilities({ + const effective = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentExecute"], declared: { incrementalSessionOutput: true }, narrowing, @@ -294,14 +295,14 @@ describe("sandbox capability contract normalizer", () => { // Parallel bidirectional file sync is opt-in and direction-neutral. It needs // both sync verbs, so a provider that verifies only one direction cannot get // the capability. An absent declaration denies it even with both verbs. - const undeclared = resolveEffectiveSandboxCapabilities({ + const undeclared = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentSyncIn", "environmentSyncOut"], declared: null, }); expect(undeclared.concurrentSyncOperations).toBe(false); // A positive declaration with both verified verbs resolves true. - const bothVerbs = resolveEffectiveSandboxCapabilities({ + const bothVerbs = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentSyncIn", "environmentSyncOut"], declared: { concurrentSyncOperations: true }, }); @@ -309,7 +310,7 @@ describe("sandbox capability contract normalizer", () => { // Only the inbound verb: the outbound prerequisite is missing, so it resolves // false. - const inOnly = resolveEffectiveSandboxCapabilities({ + const inOnly = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentSyncIn"], declared: { concurrentSyncOperations: true }, }); @@ -317,7 +318,7 @@ describe("sandbox capability contract normalizer", () => { // Only the outbound verb: the inbound prerequisite is missing, so it resolves // false. - const outOnly = resolveEffectiveSandboxCapabilities({ + const outOnly = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentSyncOut"], declared: { concurrentSyncOperations: true }, }); @@ -329,7 +330,7 @@ describe("sandbox capability contract normalizer", () => { // the capability even when the worker verifies the duplex open verb. This // matches the incremental-session-output pattern: an opt-in behavioral // guarantee needs a positive declaration, not just a verified verb. - const undeclared = resolveEffectiveSandboxCapabilities({ + const undeclared = classifyEnvironmentCapabilities({ verifiedMethods: ["duplexChannelOpen"], declared: null, }); @@ -340,7 +341,7 @@ describe("sandbox capability contract normalizer", () => { // A declaration never grants the capability without the verified duplex open // verb. A provider that declares the capability but whose worker does not // report the duplex open method resolves false. - const declaredButUnverified = resolveEffectiveSandboxCapabilities({ + const declaredButUnverified = classifyEnvironmentCapabilities({ verifiedMethods: ["environmentExecute"], declared: { duplexCommandStream: true }, }); @@ -350,7 +351,7 @@ describe("sandbox capability contract normalizer", () => { it("test_duplex_command_stream_declared_and_verified_resolves_true_but_narrowing_removes_it", () => { // A provider that declares the capability and whose worker verifies the // duplex open verb gets the capability. - const granted = resolveEffectiveSandboxCapabilities({ + const granted = classifyEnvironmentCapabilities({ verifiedMethods: ["duplexChannelOpen"], declared: { duplexCommandStream: true }, }); @@ -358,7 +359,7 @@ describe("sandbox capability contract normalizer", () => { // Per-target narrowing still removes a verified and declared capability, so a // lease that cannot use the duplex channel keeps the file bridge. - const narrowed = resolveEffectiveSandboxCapabilities({ + const narrowed = classifyEnvironmentCapabilities({ verifiedMethods: ["duplexChannelOpen"], declared: { duplexCommandStream: true }, narrowing: { duplexCommandStream: false }, @@ -378,10 +379,103 @@ describe("sandbox capability contract normalizer", () => { }; for (const verifiedMethods of [null, undefined, [] as string[]]) { - const effective = resolveEffectiveSandboxCapabilities({ verifiedMethods, declared: declaredAll }); + const effective = classifyEnvironmentCapabilities({ verifiedMethods, declared: declaredAll }); for (const key of SANDBOX_CAPABILITY_KEYS) { expect(effective[key]).toBe(false); } } }); }); + +describe("general runtime capability resolver — four-driver matrix", () => { + // A declaration that would grant every capability, paired with a worker + // method list that verifies every prerequisite. Used to probe each driver's + // static support ceiling: whatever the driver family cannot support must + // stay `false` even under the most permissive declaration and worker. + const DECLARE_ALL = { + reusableLeases: true, + nativeSyncIn: true, + nativeSyncOut: true, + persistentProcessSessions: true, + independentControlCommands: true, + incrementalSessionOutput: true, + concurrentSyncOperations: true, + duplexCommandStream: true, + }; + const VERIFY_ALL = [...ALL_PLUGIN_METHODS, "duplexChannelOpen"]; + + it("test_local_and_ssh_drivers_support_no_capability_regardless_of_declaration_or_worker", () => { + // The `local` and `ssh` static support definitions name none of the eight + // capabilities, so the classifier resolves every field `false` even with a + // full declaration and a fully verified worker. + for (const driver of ["local", "ssh"] as const) { + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: VERIFY_ALL, + declared: DECLARE_ALL, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT[driver].supportedCapabilities, + }); + for (const key of SANDBOX_CAPABILITY_KEYS) { + expect(effective[key]).toBe(false); + } + } + }); + + it("test_sandbox_and_plugin_drivers_support_the_whole_capability_set", () => { + // The `sandbox` and `plugin` static support definitions name every + // capability, so the classifier defers fully to the declaration, the + // verified worker methods, and the narrowing — the static gate adds no + // extra restriction for either driver. + for (const driver of ["sandbox", "plugin"] as const) { + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: VERIFY_ALL, + declared: DECLARE_ALL, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT[driver].supportedCapabilities, + }); + for (const key of SANDBOX_CAPABILITY_KEYS) { + expect(effective[key]).toBe(true); + } + // The static gate changes nothing versus the ungated normalizer for + // these two drivers, so the two results match field for field. + expect(effective).toEqual( + classifyEnvironmentCapabilities({ verifiedMethods: VERIFY_ALL, declared: DECLARE_ALL }), + ); + } + }); + + it("test_sandbox_and_plugin_drivers_fail_closed_on_a_missing_worker_method_list", () => { + // A missing, undefined, or empty worker method list verifies no + // prerequisite, so every capability resolves `false` for a driver that + // supports the whole set, even under a full declaration. This is the + // fail-closed contract Phase 2 must keep for the live plugin worker path. + for (const driver of ["sandbox", "plugin"] as const) { + for (const verifiedMethods of [null, undefined, [] as string[]]) { + const effective = classifyEnvironmentCapabilities({ + verifiedMethods, + declared: DECLARE_ALL, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT[driver].supportedCapabilities, + }); + for (const key of SANDBOX_CAPABILITY_KEYS) { + expect(effective[key]).toBe(false); + } + } + } + }); + + it("test_narrowing_still_removes_a_capability_the_static_support_and_declaration_both_grant", () => { + // Per-target narrowing stays a separate, later gate: it removes a + // capability that the static support, the verified worker, and the + // declaration all grant. This holds for every driver whose static support + // names the capability. + for (const driver of ["sandbox", "plugin"] as const) { + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: VERIFY_ALL, + declared: DECLARE_ALL, + narrowing: { duplexCommandStream: false }, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT[driver].supportedCapabilities, + }); + expect(effective.duplexCommandStream).toBe(false); + // A capability the narrowing does not name is unaffected. + expect(effective.persistentProcessSessions).toBe(true); + } + }); +}); diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index f3f986c406..fd9ba73957 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -66,7 +66,16 @@ function pluginManifest() { } as const; } -function createWorkerManager() { +const ALL_CUSTOM_IMAGE_WORKER_METHODS = [ + "environmentStartInteractiveSetup", + "environmentGetInteractiveSetup", + "environmentCancelInteractiveSetup", + "environmentCaptureTemplate", + "environmentDeleteTemplate", +] as const; + +function createWorkerManager(options?: { workerMethods?: readonly string[] }) { + const supportedMethods = options?.workerMethods ?? ALL_CUSTOM_IMAGE_WORKER_METHODS; const call = vi.fn(async (_pluginId: string, method: string, params: Record) => { if (method === "environmentStartInteractiveSetup") { return { @@ -134,6 +143,7 @@ function createWorkerManager() { return { call, isRunning: vi.fn(() => true), + getWorker: vi.fn(() => ({ supportedMethods: [...supportedMethods] })), } as unknown as PluginWorkerManager & { call: typeof call }; } @@ -391,6 +401,99 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { expect(timedOut?.status).toBe("timed_out"); }); + it("rejects interactive setup when the provider declares it but the worker omits a setup method", async () => { + const { environmentId } = await seed(); + // The manifest declares supportsInteractiveSetup, but the worker reports + // none of the three setup methods. The gate must name the first missing one. + const workerManager = createWorkerManager({ + workerMethods: ["environmentCaptureTemplate", "environmentDeleteTemplate"], + }); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + await expect(service.startSetupSession({ + environmentId, + actor: { userId: "user-1" }, + })).rejects.toThrow('does not report the "environmentStartInteractiveSetup" method'); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("rejects template capture when the provider declares it but the worker omits the capture method", async () => { + const { environmentId } = await seed(); + // The worker reports every setup method, so the session starts and + // finishes the interactive part. It omits environmentCaptureTemplate, so + // finishing the session must fail at the capture gate. + const workerManager = createWorkerManager({ + workerMethods: [ + "environmentStartInteractiveSetup", + "environmentGetInteractiveSetup", + "environmentCancelInteractiveSetup", + "environmentDeleteTemplate", + ], + }); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + environmentId, + actor: { userId: "user-1" }, + }); + await expect(service.finishSetupSession({ sessionId: started.session.id })) + .rejects.toThrow('does not report the "environmentCaptureTemplate" method'); + }); + + it("rejects template deletion when the provider declares it but the worker omits the delete method", async () => { + const { environmentId } = await seed(); + // The worker reports every setup and capture method but omits + // environmentDeleteTemplate, so a delete-on-disable request must fail at + // the delete gate after the template is already captured. + const workerManager = createWorkerManager({ + workerMethods: [ + "environmentStartInteractiveSetup", + "environmentGetInteractiveSetup", + "environmentCancelInteractiveSetup", + "environmentCaptureTemplate", + ], + }); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + environmentId, + actor: { userId: "user-1" }, + }); + await service.finishSetupSession({ sessionId: started.session.id }); + + await expect(service.disableTemplate({ + environmentId, + deleteProviderTemplate: true, + })).rejects.toThrow('does not report the "environmentDeleteTemplate" method'); + }); + + it("passes every gate when the provider declares each flag and the worker reports every matching method", async () => { + const { environmentId } = await seed(); + // The default worker manager reports all five methods, matching every + // flag the manifest declares. Every gate must pass. + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ + environmentId, + actor: { userId: "user-1" }, + }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + expect(promoted.template.status).toBe("active"); + + const disabled = await service.disableTemplate({ + environmentId, + deleteProviderTemplate: true, + }); + expect(disabled.status).toBe("revoked"); + expect(workerManager.call).toHaveBeenCalledWith( + expect.any(String), + "environmentDeleteTemplate", + expect.any(Object), + undefined, + ); + }); + it("rejects templates from another environment", async () => { const { environmentId } = await seed(); const otherEnvironmentId = randomUUID(); diff --git a/server/src/__tests__/environment-driver-traits.test.ts b/server/src/__tests__/environment-driver-traits.test.ts new file mode 100644 index 0000000000..408bf237aa --- /dev/null +++ b/server/src/__tests__/environment-driver-traits.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + ENVIRONMENT_DRIVER_TRAITS, + getEnvironmentDriverTraits, + type EnvironmentDriverTraits, +} from "../services/environment-driver-traits.js"; + +// The exact trait table this phase moves each of the four consumers onto. Each +// value is the value the current per-consumer driver-name condition produces +// today, so this table proves the refactor changes no decision. +const EXPECTED_TRAITS: Record> = { + local: { + realizesWorkspace: true, + runsWorkspaceOffHost: false, + confinesStagedProjects: false, + hasLeaseCapabilityModel: false, + }, + ssh: { + realizesWorkspace: true, + runsWorkspaceOffHost: true, + confinesStagedProjects: false, + hasLeaseCapabilityModel: false, + }, + sandbox: { + realizesWorkspace: true, + runsWorkspaceOffHost: true, + confinesStagedProjects: true, + hasLeaseCapabilityModel: true, + }, + plugin: { + realizesWorkspace: false, + runsWorkspaceOffHost: true, + confinesStagedProjects: false, + hasLeaseCapabilityModel: false, + }, +}; + +describe("environment driver traits", () => { + for (const [driver, expected] of Object.entries(EXPECTED_TRAITS)) { + it(`carries the exact trait row for "${driver}"`, () => { + const traits = ENVIRONMENT_DRIVER_TRAITS[driver as keyof typeof ENVIRONMENT_DRIVER_TRAITS]; + expect(traits.driver).toBe(driver); + expect(traits.realizesWorkspace).toBe(expected.realizesWorkspace); + expect(traits.runsWorkspaceOffHost).toBe(expected.runsWorkspaceOffHost); + expect(traits.confinesStagedProjects).toBe(expected.confinesStagedProjects); + expect(traits.hasLeaseCapabilityModel).toBe(expected.hasLeaseCapabilityModel); + }); + } + + it("only the sandbox driver has a lease capability model today", () => { + const withModel = Object.values(ENVIRONMENT_DRIVER_TRAITS).filter( + (traits) => traits.hasLeaseCapabilityModel, + ); + expect(withModel.map((traits) => traits.driver)).toEqual(["sandbox"]); + }); + + it("getEnvironmentDriverTraits resolves a registered driver", () => { + expect(getEnvironmentDriverTraits("sandbox")?.hasLeaseCapabilityModel).toBe(true); + expect(getEnvironmentDriverTraits("plugin")?.realizesWorkspace).toBe(false); + }); + + it("getEnvironmentDriverTraits returns null for an unknown or absent driver", () => { + expect(getEnvironmentDriverTraits("not-a-real-driver")).toBeNull(); + expect(getEnvironmentDriverTraits(null)).toBeNull(); + expect(getEnvironmentDriverTraits(undefined)).toBeNull(); + }); +}); diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index 42d8420c7f..9a2785ad9d 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -66,7 +66,7 @@ async function buildSandboxTarget(input: { supportsSync: () => input.supportsSync, syncIn: vi.fn(), syncOut: vi.fn(), - effectiveSandboxCapabilities: vi.fn(async () => { + resolveCapabilities: vi.fn(async () => { if (input.rejectResolution) { throw new Error("capability resolution failed"); } @@ -102,10 +102,10 @@ describe("resolveEnvironmentExecutionTarget effective capability snapshot", () = config: { provider: "daytona", reuseLease: true, timeoutMs: 30_000 }, }); - const effectiveSandboxCapabilities = vi.fn(async () => Object.freeze({ ...SNAPSHOT })); + const resolveCapabilities = vi.fn(async () => Object.freeze({ ...SNAPSHOT })); const environmentRuntime = { supportsSync: () => false, - effectiveSandboxCapabilities, + resolveCapabilities, } as unknown as EnvironmentRuntimeService; const target = await resolveEnvironmentExecutionTarget({ @@ -123,7 +123,7 @@ describe("resolveEnvironmentExecutionTarget effective capability snapshot", () = if (target?.kind !== "remote" || target.transport !== "sandbox") { throw new Error("expected a sandbox target"); } - expect(effectiveSandboxCapabilities).toHaveBeenCalledTimes(1); + expect(resolveCapabilities).toHaveBeenCalledTimes(1); expect(target.effectiveCapabilities).toEqual(SNAPSHOT); // The snapshot is read-only: it is frozen, so a write does not change it. @@ -160,6 +160,43 @@ describe("resolveEnvironmentExecutionTarget effective capability snapshot", () = } expect(target.effectiveCapabilities).toBeUndefined(); }); + + // The static `hasLeaseCapabilityModel` trait is `true` only for the + // `sandbox` driver in this phase (see `environment-driver-traits.ts`). An + // `ssh` lease must never reach the general resolver: the new + // `resolveCapabilities` method never returns `null` for a registered + // driver, so an ungated call would turn "no snapshot" into "every + // capability denied" for a driver this file does not otherwise gate on. + it("never calls the general capability resolver for an ssh lease", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "ssh", + config: { + host: "example.test", + port: 22, + username: "agent", + remoteWorkspacePath: "/work", + }, + }); + + const resolveCapabilities = vi.fn(async () => ({ ...SNAPSHOT })); + const environmentRuntime = { + resolveCapabilities, + } as unknown as EnvironmentRuntimeService; + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "codex_local", + environment: { id: "env-1", driver: "ssh", config: {} }, + leaseId: "lease-1", + leaseMetadata: {}, + lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never, + environmentRuntime, + }); + + expect(target?.kind).toBe("remote"); + expect(resolveCapabilities).not.toHaveBeenCalled(); + }); }); describe("effective snapshot gates the sync decision", () => { diff --git a/server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts b/server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts index 655017221e..985e7df479 100644 --- a/server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts +++ b/server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts @@ -33,7 +33,7 @@ async function buildSandboxTarget(input: { }); const runtime: Record = { supportsSync: () => false, - effectiveSandboxCapabilities: vi.fn(async () => null), + resolveCapabilities: vi.fn(async () => null), }; if (!input.omitMethod) { runtime.readSandboxDuplexBridgeInput = readSandboxDuplexBridgeInput; diff --git a/server/src/__tests__/environment-execution-target-duplex.test.ts b/server/src/__tests__/environment-execution-target-duplex.test.ts index 663653c699..0343b7e254 100644 --- a/server/src/__tests__/environment-execution-target-duplex.test.ts +++ b/server/src/__tests__/environment-execution-target-duplex.test.ts @@ -246,7 +246,7 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => { driver: "sandbox", acquireRunLease: vi.fn(), releaseRunLease: vi.fn(), - effectiveSandboxCapabilities: vi.fn(async () => ({ ...DUPLEX_ABSENT })), + resolveCapabilities: vi.fn(async () => ({ ...DUPLEX_ABSENT })), openDuplexChannel, } as unknown as EnvironmentRuntimeDriver; const service = environmentRuntimeService({} as never, { drivers: [narrowedDriver] }); @@ -373,7 +373,7 @@ async function buildSandboxRunner(input: { syncIn: vi.fn(), syncOut: vi.fn(), openDuplexChannel, - effectiveSandboxCapabilities: vi.fn(async () => + resolveCapabilities: vi.fn(async () => input.snapshot ? Object.freeze({ ...input.snapshot }) : null, ), } as unknown as EnvironmentRuntimeService; diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index e5873137af..ed5d31ab26 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -5929,7 +5929,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(reusableLease.metadata?.pluginId).toBe(pluginId); - const effective = await runtimeWithPlugin.effectiveSandboxCapabilities({ + const effective = await runtimeWithPlugin.resolveCapabilities({ environment, lease: reusableLease, }); @@ -5966,7 +5966,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); - const effective = await runtimeWithPlugin.effectiveSandboxCapabilities({ + const effective = await runtimeWithPlugin.resolveCapabilities({ environment, lease: reusableLease, }); @@ -6025,7 +6025,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); - const effective = await runtimeWithPlugin.effectiveSandboxCapabilities({ + const effective = await runtimeWithPlugin.resolveCapabilities({ environment, lease: reusableLease, }); @@ -6079,7 +6079,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); - const effective = await runtimeWithPlugin.effectiveSandboxCapabilities({ + const effective = await runtimeWithPlugin.resolveCapabilities({ environment, lease: reusableLease, }); diff --git a/server/src/__tests__/general-capability-classifier.test.ts b/server/src/__tests__/general-capability-classifier.test.ts new file mode 100644 index 0000000000..03fe0fd2ac --- /dev/null +++ b/server/src/__tests__/general-capability-classifier.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT, + SANDBOX_CAPABILITY_KEYS, + classifyEnvironmentCapabilities, +} from "../services/environment-runtime.js"; + +// The worker verbs a fully-capable provider advertises. A built-in driver maps +// its own methods onto these verb names, so the general classifier reads one +// verb vocabulary for every driver. +const ALL_PROVIDER_METHODS = [ + "environmentResumeLease", + "environmentReleaseLease", + "environmentDestroyLease", + "environmentExecute", + "environmentSyncIn", + "environmentSyncOut", + "duplexChannelOpen", +]; + +describe("general capability classifier", () => { + it("returns the eight Boolean fields for a full sandbox declaration input", () => { + // A sandbox provider that verifies every prerequisite verb and declares every + // capability resolves the whole eight-field set to true. The classifier reads + // the sandbox driver's static support definition, so it names no driver. + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: ALL_PROVIDER_METHODS, + declared: { + reusableLeases: true, + nativeSyncIn: true, + nativeSyncOut: true, + persistentProcessSessions: true, + independentControlCommands: true, + incrementalSessionOutput: true, + concurrentSyncOperations: true, + duplexCommandStream: true, + }, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities, + }); + + // The result carries exactly the eight capability fields, each a Boolean. + expect(Object.keys(effective).sort()).toEqual([...SANDBOX_CAPABILITY_KEYS].sort()); + for (const key of SANDBOX_CAPABILITY_KEYS) { + expect(typeof effective[key]).toBe("boolean"); + expect(effective[key]).toBe(true); + } + }); + + it("default rule one: an absent declaration defers to verification for a worker-property field", () => { + // A worker-property capability has no opt-in declaration. An absent + // declaration defers to the verified baseline, so a verified verb grants the + // capability and an unverified verb denies it. + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: ["environmentSyncIn", "environmentSyncOut"], + declared: null, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities, + }); + + // Verified sync verbs grant native sync without any declaration. + expect(effective.nativeSyncIn).toBe(true); + expect(effective.nativeSyncOut).toBe(true); + // The worker did not verify the execute or reuse verbs, so the baseline denies + // the matching worker-property capabilities. + expect(effective.persistentProcessSessions).toBe(false); + expect(effective.independentControlCommands).toBe(false); + expect(effective.reusableLeases).toBe(false); + }); + + it("default rule two: the three opt-in fields deny by default without a declaration", () => { + // The opt-in fields are behavioral guarantees. An absent declaration denies + // them even when the worker verifies the prerequisite verb. + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: ["environmentExecute", "environmentSyncIn", "environmentSyncOut", "duplexChannelOpen"], + declared: null, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities, + }); + + expect(effective.incrementalSessionOutput).toBe(false); + expect(effective.concurrentSyncOperations).toBe(false); + expect(effective.duplexCommandStream).toBe(false); + }); + + it("a built-in driver static definition denies every capability it does not support", () => { + // The local and SSH drivers run no provider capability model, so their static + // support definition names no capability. Every capability resolves false even + // with every verb verified and every capability declared. + for (const driver of ["local", "ssh"] as const) { + const effective = classifyEnvironmentCapabilities({ + verifiedMethods: ALL_PROVIDER_METHODS, + declared: { + reusableLeases: true, + nativeSyncIn: true, + nativeSyncOut: true, + persistentProcessSessions: true, + independentControlCommands: true, + incrementalSessionOutput: true, + concurrentSyncOperations: true, + duplexCommandStream: true, + }, + supportedCapabilities: ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT[driver].supportedCapabilities, + }); + for (const key of SANDBOX_CAPABILITY_KEYS) { + expect(effective[key]).toBe(false); + } + } + }); + + it("defines static capability support for the four drivers", () => { + expect(Object.keys(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT).sort()).toEqual([ + "local", + "plugin", + "sandbox", + "ssh", + ]); + // The two remote provider drivers support the whole capability set; the two + // host drivers support none. + expect(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities.size).toBe( + SANDBOX_CAPABILITY_KEYS.length, + ); + expect(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.plugin.supportedCapabilities.size).toBe( + SANDBOX_CAPABILITY_KEYS.length, + ); + expect(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.local.supportedCapabilities.size).toBe(0); + expect(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.ssh.supportedCapabilities.size).toBe(0); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 802d4cd94c..577f0365d0 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -63,7 +63,11 @@ import { buildWorkspaceRealizationRequest, readWorkspaceRealizationRequest, } from "../services/workspace-realization.ts"; -import { deriveViteHmrPort, type Environment, type EnvironmentLease } from "@paperclipai/shared"; +import { + deriveViteHmrPort, + type Environment, + type EnvironmentLease, +} from "@paperclipai/shared"; import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -9079,6 +9083,156 @@ describe("workspace realization request additionalSources", () => { expect(record.local.path).toBe("/anchor"); }); + // Characterization test: this test must pass on the code before and after a + // pure refactor of the driver-trait lookup. It pins today's behavior so the + // refactor changes no field the record writes. + it("selects the provider and summary from the driver's traits", () => { + const now = new Date(0); + const workspace = buildRealizedWorkspace(); + const request = buildWorkspaceRealizationRequest({ + adapterType: "codex", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + requestedMode: "shared_workspace", + workspace, + workspaceConfig: null, + }); + const lease: EnvironmentLease = { + id: "lease-1", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + status: "active", + leasePolicy: "ephemeral", + provider: null, + providerLeaseId: null, + acquiredAt: now, + lastUsedAt: now, + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: null, + metadata: null, + createdAt: now, + updatedAt: now, + }; + function buildEnvironment(driver: string): Environment { + return { + id: "environment-1", + name: "env", + description: null, + driver: driver as Environment["driver"], + status: "active", + config: {}, + envVars: {}, + metadata: null, + createdAt: now, + updatedAt: now, + }; + } + + const cases: Array<{ + driver: string; + provider: string | null; + summary: string; + }> = [ + { + driver: "local", + provider: "local", + summary: "Local workspace realized at /anchor.", + }, + { + driver: "ssh", + provider: "ssh", + summary: "SSH workspace realized at user@host:22:/anchor.", + }, + { + driver: "sandbox", + provider: null, + summary: "Sandbox workspace realized at /.", + }, + { + driver: "plugin", + provider: null, + summary: "Plugin workspace realized at /anchor.", + }, + // An unknown driver string falls back to the "local" trait row: provider "local". + { + driver: "unknown-driver", + provider: "local", + summary: "Local workspace realized at /anchor.", + }, + ]; + + for (const testCase of cases) { + const record = buildWorkspaceRealizationRecord({ + environment: buildEnvironment(testCase.driver), + lease, + request, + }); + expect(record.provider).toBe(testCase.provider); + expect(record.summary).toBe(testCase.summary); + } + }); + + it("reads the in_place mode from the lease metadata", () => { + const now = new Date(0); + const workspace = buildRealizedWorkspace(); + const request = buildWorkspaceRealizationRequest({ + adapterType: "codex", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + requestedMode: "shared_workspace", + workspace, + workspaceConfig: null, + }); + const lease: EnvironmentLease = { + id: "lease-1", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + status: "active", + leasePolicy: "ephemeral", + provider: null, + providerLeaseId: null, + acquiredAt: now, + lastUsedAt: now, + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: null, + metadata: { workspaceRealization: { mode: "in_place" } }, + createdAt: now, + updatedAt: now, + }; + const environment: Environment = { + id: "environment-1", + name: "env", + description: null, + driver: "sandbox", + status: "active", + config: {}, + envVars: {}, + metadata: null, + createdAt: now, + updatedAt: now, + }; + + const record = buildWorkspaceRealizationRecord({ environment, lease, request }); + + expect(record.mode).toBe("in_place"); + }); + it("reads a legacy request without additionalSources as an empty array", () => { const legacyRequest = { version: 1, diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index b7101ec01b..f377b2bd1a 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -33,6 +33,9 @@ import { } from "./environment-config.js"; import { secretService } from "./secrets.js"; import { + INTERACTIVE_SETUP_WORKER_METHODS, + TEMPLATE_CAPTURE_WORKER_METHODS, + TEMPLATE_DELETE_WORKER_METHODS, resolvePluginExecuteRpcTimeoutMs, resolvePluginSandboxProviderDriverByKey, } from "./plugin-environment-driver.js"; @@ -395,14 +398,33 @@ export function environmentCustomImageService( if (!resolved) { throw unprocessable(`Sandbox provider "${provider}" is not ready for customImage setup.`); } + // A manifest declaration is a claim. Read the live worker's verified + // methods once, so each gate below requires the declaration AND the + // matching method the worker really implements. + const workerMethods = new Set(options.pluginWorkerManager.getWorker(resolved.plugin.id)?.supportedMethods ?? []); + const requireWorkerMethods = (methods: readonly string[], capabilityLabel: string) => { + const missingMethod = methods.find((method) => !workerMethods.has(method)); + if (missingMethod) { + throw unprocessable( + `Sandbox provider "${provider}" declares ${capabilityLabel} but its worker does not report the "${missingMethod}" method.`, + ); + } + }; if (!resolved.driver.supportsInteractiveSetup) { throw unprocessable(`Sandbox provider "${provider}" does not support interactive setup.`); } - if (input.requireCapture && !resolved.driver.supportsTemplateCapture) { - throw unprocessable(`Sandbox provider "${provider}" does not support template capture.`); + requireWorkerMethods(INTERACTIVE_SETUP_WORKER_METHODS, "interactive setup"); + if (input.requireCapture) { + if (!resolved.driver.supportsTemplateCapture) { + throw unprocessable(`Sandbox provider "${provider}" does not support template capture.`); + } + requireWorkerMethods(TEMPLATE_CAPTURE_WORKER_METHODS, "template capture"); } - if (input.requireDelete && !resolved.driver.supportsTemplateDelete) { - throw unprocessable(`Sandbox provider "${provider}" does not support template deletion.`); + if (input.requireDelete) { + if (!resolved.driver.supportsTemplateDelete) { + throw unprocessable(`Sandbox provider "${provider}" does not support template deletion.`); + } + requireWorkerMethods(TEMPLATE_DELETE_WORKER_METHODS, "template deletion"); } return { provider, diff --git a/server/src/services/environment-driver-traits.ts b/server/src/services/environment-driver-traits.ts new file mode 100644 index 0000000000..989cc4f6bf --- /dev/null +++ b/server/src/services/environment-driver-traits.ts @@ -0,0 +1,175 @@ +/** + * The static per-driver traits for the four environment drivers. + * + * This module is a dependency leaf: it imports no other service module. It + * imports one type from `environment-runtime.ts` (`SandboxCapabilityKey`), but + * a type-only import compiles to nothing, so it adds no runtime dependency + * edge. `environment-runtime.ts` imports `workspace-realization.ts` at value + * level, so `workspace-realization.ts` cannot import `environment-runtime.ts` + * back. It imports this leaf module instead, and this module stays a leaf so + * the import graph has no cycle. + * + * Each trait below is a fixed fact about a driver family. It is a code fact, + * not a fact about one lease, so a consumer reads it as a plain table lookup + * instead of a driver-name condition. + */ + +import type { EnvironmentDriver } from "@paperclipai/shared"; +import type { SandboxCapabilityKey } from "./environment-runtime.js"; + +/** + * The static capability support definition for one environment driver. It names + * the eight capabilities the driver family can support at all. A capability the + * definition does not name resolves `false` for the driver, whatever a + * declaration, a verified verb, or a narrowing says. The general classifier + * reads this static definition for a built-in driver in place of a live worker + * method list. + * + * The definition is the code-level ground truth for a driver. It replaces a + * driver-identity condition: a consumer asks the classifier for a capability + * instead of matching the driver name. + */ +export interface EnvironmentDriverCapabilitySupport { + readonly driver: EnvironmentDriver; + /** The capability keys the driver family can support. */ + readonly supportedCapabilities: ReadonlySet; +} + +const NO_CAPABILITY_SUPPORT: ReadonlySet = new Set(); + +// The eight capability keys, written out here as literal strings. This module +// does not import `environment-runtime.ts` as a value (see the module comment +// above), so it cannot read `SANDBOX_CAPABILITY_KEYS` from there. Keep this +// list equal to that list. +const ALL_CAPABILITY_SUPPORT: ReadonlySet = new Set([ + "reusableLeases", + "nativeSyncIn", + "nativeSyncOut", + "persistentProcessSessions", + "independentControlCommands", + "incrementalSessionOutput", + "concurrentSyncOperations", + "duplexCommandStream", +]); + +/** + * The static capability support for the four environment drivers. + * + * - `local` runs commands on the host file system with no provider capability + * model, so it supports none of the eight capabilities. Every capability + * resolves `false`. + * - `ssh` runs commands on a remote host through the SSH transport with no + * provider capability model, so it supports none of the eight capabilities + * either. + * - `sandbox` runs the full provider capability model. A built-in provider maps + * its own methods through `builtinSandboxProviderVerifiedMethods`, and a + * plugin-backed provider reports live worker methods, so the driver can support + * every capability. The classifier still intersects the per-provider + * declaration, the verified methods, and the per-lease narrowing. + * - `plugin` resolves its capabilities from the live plugin worker method list, + * so it can support every capability. The classifier intersects the live + * verified methods and the declaration. + */ +export const ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT: Record< + EnvironmentDriver, + EnvironmentDriverCapabilitySupport +> = { + local: { driver: "local", supportedCapabilities: NO_CAPABILITY_SUPPORT }, + ssh: { driver: "ssh", supportedCapabilities: NO_CAPABILITY_SUPPORT }, + sandbox: { driver: "sandbox", supportedCapabilities: ALL_CAPABILITY_SUPPORT }, + plugin: { driver: "plugin", supportedCapabilities: ALL_CAPABILITY_SUPPORT }, +}; + +/** + * The static per-driver traits every runtime consumer reads instead of a + * driver-identity condition. Each field is a fixed code fact about the driver + * family; it never varies per lease. + */ +export interface EnvironmentDriverTraits { + readonly driver: EnvironmentDriver; + /** + * True when the driver realizes a workspace through the runtime driver's + * `realizeWorkspace` method. Read by the run orchestrator + * (`environment-run-orchestrator.ts`, `realizeForRun`) to decide whether to + * call the driver before it resolves the execution target. The `plugin` + * driver skips this step: its execution target resolves the workspace + * itself, so the orchestrator never calls `realizeWorkspace` for it. + */ + readonly realizesWorkspace: boolean; + /** + * True when the driver runs the workspace on a target other than the host + * file system. Read by `isRemoteExecutionEnvironmentDriver` + * (`heartbeat.ts`) to decide whether a host-local directory path is present + * on the run target. + */ + readonly runsWorkspaceOffHost: boolean; + /** + * True when the driver stages a multi-source remote workspace through a + * confined per-project runtime. Read by `isConfinedRemoteStagingDriver` + * (`heartbeat.ts`) to decide whether a referenced (mentioned) project + * stages under that confinement guard. + */ + readonly confinesStagedProjects: boolean; + /** + * True when the driver resolves a per-lease capability snapshot that a + * consumer reads today. Read by `resolveEnvironmentExecutionTarget` + * (`environment-execution-target.ts`) to decide whether to call the + * general capability resolver for a lease on this driver. + * + * Only the `sandbox` driver has a consumer today. The `plugin` driver + * implements `resolveCapabilities`, but no consumer reads a plugin + * snapshot yet, so this stays `false` for `plugin` here. A change to + * `true` changes runtime behavior, so the board owns that decision. + */ + readonly hasLeaseCapabilityModel: boolean; +} + +/** + * The static traits for the four environment drivers. See {@link + * EnvironmentDriverTraits} for what each field means and which consumer reads + * it. + */ +export const ENVIRONMENT_DRIVER_TRAITS: Record = { + local: { + driver: "local", + realizesWorkspace: true, + runsWorkspaceOffHost: false, + confinesStagedProjects: false, + hasLeaseCapabilityModel: false, + }, + ssh: { + driver: "ssh", + realizesWorkspace: true, + runsWorkspaceOffHost: true, + confinesStagedProjects: false, + hasLeaseCapabilityModel: false, + }, + sandbox: { + driver: "sandbox", + realizesWorkspace: true, + runsWorkspaceOffHost: true, + confinesStagedProjects: true, + hasLeaseCapabilityModel: true, + }, + plugin: { + driver: "plugin", + realizesWorkspace: false, + runsWorkspaceOffHost: true, + confinesStagedProjects: false, + hasLeaseCapabilityModel: false, + }, +}; + +/** + * Read the static traits for a driver key. Returns `null` for an unknown or + * absent driver, so a caller applies its own fallback default (the four + * drivers above are the only registered drivers today). + */ +export function getEnvironmentDriverTraits( + driver: string | null | undefined, +): EnvironmentDriverTraits | null { + if (!driver) return null; + return Object.prototype.hasOwnProperty.call(ENVIRONMENT_DRIVER_TRAITS, driver) + ? ENVIRONMENT_DRIVER_TRAITS[driver as EnvironmentDriver] + : null; +} diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index c792929f50..635278bc1c 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -18,6 +18,7 @@ import { parseObject } from "../adapters/utils.js"; import { getStartupTracer } from "../instrumentation.js"; import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; import type { EnvironmentRuntimeService } from "./environment-runtime.js"; +import { getEnvironmentDriverTraits } from "./environment-driver-traits.js"; export const DEFAULT_SANDBOX_REMOTE_CWD = "/tmp"; @@ -254,18 +255,29 @@ export async function resolveEnvironmentExecutionTarget(input: { // a recording tracer. const tracer = input.tracer ?? getStartupTracer(); - // Resolve the read-only effective capability snapshot for this lease. The - // runtime resolves it as the provider declaration ∩ the verified worker - // methods ∩ narrowing. Freeze it so a consumer reads it but never changes - // it. Track a resolution error apart from a genuinely absent snapshot: a - // rejected resolution must not read as an open grant. + // Resolve the read-only effective capability snapshot for this lease + // through the general resolver. Freeze it so a consumer reads it but + // never changes it. Track a resolution error apart from a genuinely + // absent snapshot: a rejected resolution must not read as an open grant. + // + // Gate the call on the `hasLeaseCapabilityModel` trait, not on whether the + // service exposes the method: the general resolver's `resolveCapabilities` + // never returns `null` for a registered driver, and it resolves every + // capability `false` for a driver with no lease capability model (see + // `ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT`). Calling it unconditionally + // would turn "no snapshot" into "every capability denied" for a driver + // this branch does not otherwise gate on. Reading the trait keeps that + // behavior change out of this phase: only the `sandbox` driver has a + // lease capability model today, and this branch only runs for `sandbox`. let effectiveCapabilities: Awaited< - ReturnType> + ReturnType> > | null = null; let capabilityResolutionFailed = false; - if (input.environmentRuntime?.effectiveSandboxCapabilities && input.lease) { + const driverHasLeaseCapabilityModel = + getEnvironmentDriverTraits(input.environment.driver)?.hasLeaseCapabilityModel ?? false; + if (driverHasLeaseCapabilityModel && input.environmentRuntime?.resolveCapabilities && input.lease) { try { - effectiveCapabilities = await input.environmentRuntime.effectiveSandboxCapabilities({ + effectiveCapabilities = await input.environmentRuntime.resolveCapabilities({ environment: input.environment as Environment, lease: input.lease, }); diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index a6db690eee..8ef5aa9d03 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -32,6 +32,7 @@ import { type EnvironmentRuntimeLeaseRecord, type EnvironmentRuntimeService, } from "./environment-runtime.js"; +import { ENVIRONMENT_DRIVER_TRAITS } from "./environment-driver-traits.js"; import { resolveEnvironmentExecutionTarget, resolveEnvironmentExecutionTransport, @@ -391,11 +392,7 @@ export function environmentRunOrchestrator( // Step 2: Realize workspace in the environment via the runtime driver let workspaceRealization: Record = {}; let realizedWorkspaceCwd: string | null = null; - if ( - environment.driver === "local" || - environment.driver === "ssh" || - environment.driver === "sandbox" - ) { + if (ENVIRONMENT_DRIVER_TRAITS[environment.driver].realizesWorkspace) { try { const remoteCwd = typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0 diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 6115ecd0d2..a87f517ff5 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -218,33 +218,58 @@ export function builtinSandboxProviderVerifiedMethods( 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 normalizer for the sandbox capability contract. It resolves the - * effective capability as verified ∩ declared ∩ narrowing. + * 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; it never grants a capability. A present flag - * can only remove a verified capability, never add one. - * - `narrowing` is the per-target restriction from the config or lease. An - * absent key applies no restriction; a `false` value removes the capability. + * 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. * - * A declaration never grants a capability the runtime did not verify, so a - * declared capability whose worker lacks a prerequisite verb resolves `false`. + * 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 resolveEffectiveSandboxCapabilities(input: { +export function classifyEnvironmentCapabilities(input: { verifiedMethods?: readonly string[] | null; declared?: Partial | null; narrowing?: Partial> | null; + supportedCapabilities?: ReadonlySet | null; }): EffectiveSandboxCapabilities { 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 @@ -559,10 +584,29 @@ export interface EnvironmentRuntimeDriver { /** True when the lease's plugin worker advertises both sync verbs. */ supportsSync?(input: EnvironmentDriverLeaseInput): boolean; /** - * Resolve the read-only effective sandbox capability snapshot for a lease. - * Only the sandbox driver implements it; other drivers omit it. + * 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. */ - effectiveSandboxCapabilities?(input: EnvironmentDriverLeaseInput): Promise; + resolveCapabilities(input: EnvironmentDriverLeaseInput): Promise; /** * Retry the provider teardown for an orphan sandbox that an earlier acquire * provisioned but could not tear down. The pending-cleanup lease row carries @@ -997,6 +1041,16 @@ function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { }, }; }, + + 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, + }); + }, }; } @@ -1060,6 +1114,15 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { }, }; }, + + 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, + }); + }, }; } @@ -2504,83 +2567,8 @@ function createSandboxEnvironmentDriver( return adaptDuplexChannelHostSession(session); }, - async effectiveSandboxCapabilities(input) { - 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 resolveEffectiveSandboxCapabilities({ verifiedMethods: [], declared: null, narrowing: null }); - } - - 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 resolveEffectiveSandboxCapabilities({ - verifiedMethods: [], - declared: null, - narrowing: null, - }); - } - 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 resolveEffectiveSandboxCapabilities({ verifiedMethods, declared, narrowing }); + async resolveCapabilities(input) { + return await resolveSandboxCapabilitiesForLease(input); }, async destroyRunLease(input) { @@ -2592,6 +2580,108 @@ function createSandboxEnvironmentDriver( }, }; + /** + * 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 { + 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 @@ -3099,6 +3189,57 @@ function createPluginEnvironmentDriver( }, }); }, + + 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, + }); + }, }; } @@ -3569,11 +3710,19 @@ export function environmentRuntimeService( return driver?.supportsSync?.(input) ?? false; }, - async effectiveSandboxCapabilities( + /** + * 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 { const driver = getDriver(getLeaseDriverKey(input.lease, input.environment)); - return (await driver?.effectiveSandboxCapabilities?.(input)) ?? null; + if (!driver) return null; + return await driver.resolveCapabilities(input); }, async syncIn(input: EnvironmentDriverSyncInput): Promise { @@ -3600,14 +3749,14 @@ export function environmentRuntimeService( throw new Error(`Environment driver "${driver.driver}" does not support duplex channels.`); } // Centralize the duplex channel authorization here. Resolve the exact lease - // capability snapshot 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.effectiveSandboxCapabilities?.(input)) ?? null; - if (effective?.duplexCommandStream !== true) { + // 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); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4a665fc0d1..b85486061f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -158,6 +158,7 @@ import { } from "./workspace-instance-cleanup.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; +import { getEnvironmentDriverTraits } from "./environment-driver-traits.js"; import { authorizationService, type AuthorizationActor } from "./authorization.js"; import { createToolGatewayService } from "./tool-gateway.js"; import { toolAccessService } from "./tool-access.js"; @@ -2845,7 +2846,7 @@ export function isMultiProjectWorkspaceSyncEnabled( * treated as local. */ export function isRemoteExecutionEnvironmentDriver(driver: string | null | undefined): boolean { - return driver === "ssh" || driver === "sandbox" || driver === "plugin"; + return getEnvironmentDriverTraits(driver)?.runsWorkspaceOffHost ?? false; } /** @@ -2880,7 +2881,7 @@ export function isMultiProjectWorkspaceSyncRemoteEnabled( * confines each staged referenced tree. */ export function isConfinedRemoteStagingDriver(driver: string | null | undefined): boolean { - return driver === "sandbox"; + return getEnvironmentDriverTraits(driver)?.confinesStagedProjects ?? false; } /** diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 6ef33f982f..e9c4751985 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -49,6 +49,31 @@ export const REUSABLE_LEASE_WORKER_METHODS = [ "environmentDestroyLease", ] as const; +/** + * The worker methods a sandbox provider must advertise before the customImage + * setup gate in `environment-custom-images.ts` allows an interactive setup + * session. The setup lifecycle starts a session through + * `environmentStartInteractiveSetup`, polls it through + * `environmentGetInteractiveSetup`, and cancels it through + * `environmentCancelInteractiveSetup`. A provider that omits any of the three + * can strand a setup session partway through the lifecycle. + */ +export const INTERACTIVE_SETUP_WORKER_METHODS = [ + "environmentStartInteractiveSetup", + "environmentGetInteractiveSetup", + "environmentCancelInteractiveSetup", +] as const; + +/** The worker method the customImage template-capture gate in `environment-custom-images.ts` requires. */ +export const TEMPLATE_CAPTURE_WORKER_METHODS = [ + "environmentCaptureTemplate", +] as const; + +/** The worker method the customImage template-delete gate in `environment-custom-images.ts` requires. */ +export const TEMPLATE_DELETE_WORKER_METHODS = [ + "environmentDeleteTemplate", +] as const; + export interface ReadyPluginWorkerRecovery { pluginKeys: readonly string[]; startWorker(plugin: { id: string; pluginKey: string }): Promise; diff --git a/server/src/services/workspace-realization.ts b/server/src/services/workspace-realization.ts index 8f908d3300..f69e393174 100644 --- a/server/src/services/workspace-realization.ts +++ b/server/src/services/workspace-realization.ts @@ -6,6 +6,7 @@ import type { WorkspaceRealizationRequest, } from "@paperclipai/shared"; import type { RealizedExecutionWorkspace } from "./workspace-runtime.js"; +import { ENVIRONMENT_DRIVER_TRAITS, getEnvironmentDriverTraits } from "./environment-driver-traits.js"; function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -170,10 +171,9 @@ export function buildWorkspaceRealizationRecord(input: { }): WorkspaceRealizationRecord { const leaseMetadata = input.lease.metadata ?? {}; const providerMetadata = input.providerMetadata ?? {}; - const transport = - input.environment.driver === "ssh" || input.environment.driver === "sandbox" || input.environment.driver === "plugin" - ? input.environment.driver - : "local"; + // An unknown or absent driver reads the "local" row, same as today's fallback. + const traits = getEnvironmentDriverTraits(input.environment.driver) ?? ENVIRONMENT_DRIVER_TRAITS.local; + const transport = traits.driver; const remotePath = readString(providerMetadata.remoteCwd) ?? readString(leaseMetadata.remoteCwd) ?? @@ -198,35 +198,6 @@ export function buildWorkspaceRealizationRecord(input: { const pathAliases = readPathAliases(realizationMetadata.pathAliases ?? realizationMetadata.workspaceAliases); const outboundRestorePaths = readStringArray(realizationMetadata.outboundRestorePaths); - const sync = (() => { - if (mode === "in_place" || transport === "local") { - return { - strategy: "none" as const, - prepare: "Use the realized local execution workspace directly.", - syncBack: null, - }; - } - if (transport === "ssh") { - return { - strategy: "ssh_git_import_export" as const, - prepare: "Import the local git workspace to the remote SSH workspace before adapter execution.", - syncBack: "Export remote SSH workspace changes back to the local execution workspace after adapter execution.", - }; - } - if (transport === "sandbox") { - return { - strategy: "sandbox_archive_upload_download" as const, - prepare: "Upload a workspace archive into the sandbox filesystem before adapter execution.", - syncBack: "Download a workspace archive from the sandbox and mirror it back locally after adapter execution.", - }; - } - return { - strategy: "provider_defined" as const, - prepare: "Delegate workspace materialization to the plugin environment driver.", - syncBack: "Delegate result synchronization to the plugin environment driver.", - }; - })(); - const provider = input.lease.provider ?? (transport === "ssh" ? "ssh" : transport === "local" ? "local" : null); @@ -246,7 +217,6 @@ export function buildWorkspaceRealizationRecord(input: { authoritativeRoot, pathAliases, outboundRestorePaths, - transport, provider, environmentId: input.environment.id, leaseId: input.lease.id, @@ -276,7 +246,6 @@ export function buildWorkspaceRealizationRecord(input: { ...(username ? { username } : {}), ...(sandboxId ? { sandboxId } : {}), }, - sync, bootstrap: { command: input.request.runtimeOverlay.provisionCommand, },