From a14e51d592dd22e2e830e01f94e6783d55df9963 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Sun, 23 Aug 2026 21:24:59 -0700 Subject: [PATCH] refactor(environment): classify environment capabilities from static driver definitions (#12045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environment runtime drivers provide workspace, lease, and custom image behavior > - Runtime code used driver identity checks and several capability-specific members > - These checks spread capability rules across the runtime and made new drivers harder to verify > - This pull request adds one general capability classifier and one static driver support table > - The benefit is one fail-closed capability model that keeps current behavior and supports future drivers ## Linked Issues or Issue Description **What existing behavior does this improve?** Environment runtime capability checks for workspace realization, custom images, lease capabilities, and duplex authorization. **Subsystem affected** Cross-cutting (multiple of the above) **Current behavior** The runtime selects several capability paths from driver identity and separate capability members. Custom image gates also trust provider declarations without checking every matching live worker method. **Proposed behavior** The runtime uses one general capability classifier and one static support table. Custom image gates require both the provider declaration and every matching live worker method. The public capability names remain unchanged. **Reason and benefit** The change keeps capability rules in one place. It removes identity conditions from runtime consumers and makes unsupported drivers fail closed. **Breaking changes** None. The public names sandboxCapabilities, sandboxProviders, and EffectiveSandboxCapabilities remain available. ## What Changed - Add classifyEnvironmentCapabilities and static support definitions for all four driver families. - Add resolveCapabilities to every environment runtime driver. - Move driver traits into environment-driver-traits.ts and migrate runtime consumers. - Require provider declarations and matching live worker methods for all custom image gates. - Migrate duplex authorization to the general resolver and remove the dead sandbox-only member. - Delete the unused resolveEffectiveSandboxCapabilities wrapper and update its test. ## Verification - pnpm --filter @paperclipai/server typecheck - pnpm exec vitest run server/src/__tests__/environment-capability-contract.test.ts server/src/__tests__/environment-runtime.test.ts — 92 tests pass - pnpm exec vitest run server/src/__tests__/environment-driver-traits.test.ts server/src/__tests__/general-capability-classifier.test.ts — 12 tests pass - pnpm exec vitest run server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/environment-execution-target-capabilities.test.ts server/src/__tests__/environment-execution-target-duplex.test.ts server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts — 66 tests pass ## Risks The main risk is a capability gate that denies a valid driver or permits an invalid driver. The static support matrix, live worker method checks, and regression tests reduce this risk. No database, public API, or published type name changes. ## Model Used OpenAI Codex, GPT-5, with tool use and code execution. The deployment does not provide a separate context-window value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs) - [x] My branch name describes the change (for example, docs/... or fix/...) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/index.ts | 2 - packages/shared/src/types/index.ts | 2 - .../shared/src/types/workspace-runtime.ts | 13 - ...> environment-capability-contract.test.ts} | 152 ++++++-- .../environment-custom-images-service.test.ts | 105 +++++- .../environment-driver-traits.test.ts | 67 ++++ ...ment-execution-target-capabilities.test.ts | 45 ++- ...xecution-target-duplex-kill-switch.test.ts | 2 +- ...nvironment-execution-target-duplex.test.ts | 4 +- .../src/__tests__/environment-runtime.test.ts | 8 +- .../general-capability-classifier.test.ts | 126 +++++++ .../src/__tests__/workspace-runtime.test.ts | 156 +++++++- .../src/services/environment-custom-images.ts | 30 +- .../src/services/environment-driver-traits.ts | 175 +++++++++ .../services/environment-execution-target.ts | 28 +- .../services/environment-run-orchestrator.ts | 7 +- server/src/services/environment-runtime.ts | 347 +++++++++++++----- server/src/services/heartbeat.ts | 5 +- .../src/services/plugin-environment-driver.ts | 25 ++ server/src/services/workspace-realization.ts | 39 +- 20 files changed, 1126 insertions(+), 212 deletions(-) rename server/src/__tests__/{sandbox-capability-contract.test.ts => environment-capability-contract.test.ts} (70%) create mode 100644 server/src/__tests__/environment-driver-traits.test.ts create mode 100644 server/src/__tests__/general-capability-classifier.test.ts create mode 100644 server/src/services/environment-driver-traits.ts 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, },