From 5bd0f578fd3f44aac910aab774443980696f4f97 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 24 Apr 2026 18:03:41 -0700 Subject: [PATCH] Generalize sandbox provider core for plugin-only providers (#4449) ## Thinking Path > - Paperclip is a control plane, so optional execution providers should sit at the plugin edge instead of hardcoding provider-specific behavior into core shared/server/ui layers. > - Sandbox environments are already first-class, and the fake provider proves the built-in path; the remaining gap was that real providers still leaked provider-specific config and runtime assumptions into core. > - That coupling showed up in config normalization, secret persistence, capabilities reporting, lease reconstruction, and the board UI form fields. > - As long as core knew about those provider-shaped details, shipping a provider as a pure third-party plugin meant every new provider would still require host changes. > - This pull request generalizes the sandbox provider seam around schema-driven plugin metadata and generic secret-ref handling. > - The runtime and UI now consume provider metadata generically, so core only special-cases the built-in fake provider while third-party providers can live entirely in plugins. ## What Changed - Added generic sandbox-provider capability metadata so plugin-backed providers can expose `configSchema` through shared environment support and the environments capabilities API. - Reworked sandbox config normalization/persistence/runtime resolution to handle schema-declared secret-ref fields generically, storing them as Paperclip secrets and resolving them for probe/execute/release flows. - Generalized plugin sandbox runtime handling so provider validation, reusable-lease matching, lease reconstruction, and plugin worker calls all operate on provider-agnostic config instead of provider-shaped branches. - Replaced hardcoded sandbox provider form fields in Company Settings with schema-driven rendering and blocked agent environment selection from the built-in fake provider. - Added regression coverage for the generic seam across shared support helpers plus environment config, probe, routes, runtime, and sandbox-provider runtime tests. ## Verification - `pnpm vitest --run packages/shared/src/environment-support.test.ts server/src/__tests__/environment-config.test.ts server/src/__tests__/environment-probe.test.ts server/src/__tests__/environment-routes.test.ts server/src/__tests__/environment-runtime.test.ts server/src/__tests__/sandbox-provider-runtime.test.ts` - `pnpm -r typecheck` ## Risks - Plugin sandbox providers now depend more heavily on accurate `configSchema` declarations; incorrect schemas can misclassify secret-bearing fields or omit required config. - Reusable lease matching is now metadata-driven for plugin-backed providers, so providers that fail to persist stable metadata may reprovision instead of resuming an existing lease. - The UI form is now fully schema-driven for plugin-backed sandbox providers; provider manifests without good defaults or descriptions may produce a rougher operator experience. ## Model Used - OpenAI Codex via `codex_local` - Model ID: `gpt-5.4` - Reasoning effort: `high` - Context window observed in runtime session metadata: `258400` tokens - Capabilities used: terminal tool execution, git, and local code/test inspection ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/shared/src/environment-support.ts | 5 +- packages/shared/src/types/environment.ts | 10 - .../src/__tests__/environment-config.test.ts | 44 +++ .../src/__tests__/environment-probe.test.ts | 4 + .../src/__tests__/environment-routes.test.ts | 176 ++++++++++- .../src/__tests__/environment-runtime.test.ts | 282 +++++++++++++++++- .../__tests__/json-schema-secret-refs.test.ts | 44 +++ .../sandbox-provider-runtime.test.ts | 60 ++++ server/src/routes/environments.ts | 5 +- server/src/services/environment-config.ts | 192 ++++++++++-- server/src/services/environment-runtime.ts | 150 +++++++--- .../src/services/json-schema-secret-refs.ts | 79 +++++ .../src/services/plugin-environment-driver.ts | 76 ++++- server/src/services/plugin-secrets-handler.ts | 60 +--- .../src/services/sandbox-provider-runtime.ts | 19 +- ui/src/components/AgentConfigForm.tsx | 7 +- ui/src/pages/CompanySettings.test.tsx | 83 ++++++ ui/src/pages/CompanySettings.tsx | 175 +++++------ 18 files changed, 1235 insertions(+), 236 deletions(-) create mode 100644 server/src/__tests__/json-schema-secret-refs.test.ts create mode 100644 server/src/services/json-schema-secret-refs.ts diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index af1438c8..0ebb3ff9 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -1,5 +1,6 @@ import type { AgentAdapterType, EnvironmentDriver } from "./constants.js"; import type { SandboxEnvironmentProvider } from "./types/environment.js"; +import type { JsonSchema } from "./types/plugin.js"; export type EnvironmentSupportStatus = "supported" | "unsupported"; @@ -20,6 +21,7 @@ export interface EnvironmentProviderCapability { source?: "builtin" | "plugin"; pluginKey?: string; pluginId?: string; + configSchema?: JsonSchema; } export interface EnvironmentCapabilities { @@ -81,7 +83,7 @@ export function getAdapterEnvironmentSupport( const supportedDrivers = new Set(supportedEnvironmentDriversForAdapter(adapterType)); const supportedProviders = new Set(supportedSandboxProvidersForAdapter(adapterType, additionalSandboxProviders)); const sandboxProviders: Record = { - fake: supportedProviders.has("fake") ? "supported" : "unsupported", + fake: "unsupported", }; for (const provider of additionalSandboxProviders) { sandboxProviders[provider as SandboxEnvironmentProvider] = supportedProviders.has(provider as SandboxEnvironmentProvider) @@ -130,6 +132,7 @@ export function getEnvironmentCapabilities( source: capability.source ?? "plugin", pluginKey: capability.pluginKey, pluginId: capability.pluginId, + configSchema: capability.configSchema, }; } return { diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 917cd4ed..68f3e724 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -22,16 +22,6 @@ export interface SshEnvironmentConfig { strictHostKeyChecking: boolean; } -/** - * Known sandbox environment provider keys. - * - * `"fake"` is a built-in test-only provider. - * - * Additional providers can be added by installing sandbox provider plugins - * that declare matching `environmentDrivers` in their manifest. The type - * includes `string` to allow plugin-backed providers without requiring - * shared type changes. - */ export type SandboxEnvironmentProvider = "fake" | (string & {}); export interface FakeSandboxEnvironmentConfig { diff --git a/server/src/__tests__/environment-config.test.ts b/server/src/__tests__/environment-config.test.ts index 3125a9b7..76923fe2 100644 --- a/server/src/__tests__/environment-config.test.ts +++ b/server/src/__tests__/environment-config.test.ts @@ -141,6 +141,26 @@ describe("environment config helpers", () => { }); }); + it("normalizes schema-driven sandbox config into the generic plugin-backed stored shape", () => { + const config = normalizeEnvironmentConfig({ + driver: "sandbox", + config: { + provider: "secure-plugin", + template: " base ", + apiKey: "22222222-2222-2222-2222-222222222222", + timeoutMs: "450000", + }, + }); + + expect(config).toEqual({ + provider: "secure-plugin", + template: " base ", + apiKey: "22222222-2222-2222-2222-222222222222", + timeoutMs: 450000, + reuseLease: false, + }); + }); + it("normalizes plugin-backed sandbox provider config without server provider changes", () => { const config = normalizeEnvironmentConfig({ driver: "sandbox", @@ -162,6 +182,30 @@ describe("environment config helpers", () => { }); }); + it("parses a persisted schema-driven sandbox environment into a typed driver config", () => { + const parsed = parseEnvironmentDriverConfig({ + driver: "sandbox", + config: { + provider: "secure-plugin", + template: "base", + apiKey: "22222222-2222-2222-2222-222222222222", + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(parsed).toEqual({ + driver: "sandbox", + config: { + provider: "secure-plugin", + template: "base", + apiKey: "22222222-2222-2222-2222-222222222222", + timeoutMs: 300000, + reuseLease: true, + }, + }); + }); + it("parses a persisted plugin-backed sandbox environment into a typed driver config", () => { const parsed = parseEnvironmentDriverConfig({ driver: "sandbox", diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index 22c835d9..cd5d8715 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn()); const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn()); const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn()); +const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady, @@ -11,6 +12,7 @@ vi.mock("@paperclipai/adapter-utils/ssh", () => ({ vi.mock("../services/plugin-environment-driver.js", () => ({ probePluginEnvironmentDriver: mockProbePluginEnvironmentDriver, probePluginSandboxProviderDriver: mockProbePluginSandboxProviderDriver, + resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey, })); import { probeEnvironment } from "../services/environment-probe.ts"; @@ -20,6 +22,8 @@ describe("probeEnvironment", () => { mockEnsureSshWorkspaceReady.mockReset(); mockProbePluginEnvironmentDriver.mockReset(); mockProbePluginSandboxProviderDriver.mockReset(); + mockResolvePluginSandboxProviderDriverByKey.mockReset(); + mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null); }); it("reports local environments as immediately available", async () => { diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 7051b87c..3c9ecdb4 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -38,6 +38,7 @@ const mockSecretService = vi.hoisted(() => ({ resolveSecretValue: vi.fn(), })); const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn()); +const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn()); const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn()); const mockExecutionWorkspaceService = vi.hoisted(() => ({})); @@ -69,6 +70,7 @@ vi.mock("../services/execution-workspaces.js", () => ({ vi.mock("../services/plugin-environment-driver.js", () => ({ listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers, validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig, + validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig, })); function createEnvironment() { @@ -148,6 +150,18 @@ describe("environment routes", () => { }); mockValidatePluginEnvironmentDriverConfig.mockReset(); mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config); + mockValidatePluginSandboxProviderConfig.mockReset(); + mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ provider, config }) => ({ + normalizedConfig: config, + pluginId: `plugin-${provider}`, + pluginKey: `plugin.${provider}`, + driver: { + driverKey: provider, + kind: "sandbox_provider", + displayName: provider, + configSchema: { type: "object" }, + }, + })); mockListReadyPluginEnvironmentDrivers.mockReset(); mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]); }); @@ -185,6 +199,52 @@ describe("environment routes", () => { expect(res.body.sandboxProviders).not.toHaveProperty("fake-plugin"); }); + it("returns installed plugin-backed sandbox capabilities for environment creation", async () => { + mockListReadyPluginEnvironmentDrivers.mockResolvedValue([ + { + pluginId: "plugin-1", + pluginKey: "acme.secure-sandbox-provider", + driverKey: "secure-plugin", + displayName: "Secure Sandbox", + description: "Provisions schema-driven cloud sandboxes.", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + }, + }, + }, + ]); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).get("/api/companies/company-1/environments/capabilities"); + + expect(res.status).toBe(200); + expect(res.body.sandboxProviders["secure-plugin"]).toMatchObject({ + status: "supported", + supportsRunExecution: true, + supportsReusableLeases: true, + displayName: "Secure Sandbox", + source: "plugin", + pluginKey: "acme.secure-sandbox-provider", + pluginId: "plugin-1", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + }, + }, + }); + expect(res.body.adapters.find((row: any) => row.adapterType === "codex_local").sandboxProviders["secure-plugin"]) + .toBe("supported"); + }); + it("redacts config and metadata for unprivileged agent list reads", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); mockAgentService.getById.mockResolvedValue({ @@ -453,11 +513,12 @@ describe("environment routes", () => { }, }; mockEnvironmentService.create.mockResolvedValue(environment); + const pluginWorkerManager = {}; const app = createApp({ type: "board", userId: "user-1", source: "local_implicit", - }); + }, { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -531,11 +592,12 @@ describe("environment routes", () => { }, }; mockEnvironmentService.create.mockResolvedValue(environment); + const pluginWorkerManager = {}; const app = createApp({ type: "board", userId: "user-1", source: "local_implicit", - }); + }, { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -551,6 +613,16 @@ describe("environment routes", () => { }); expect(res.status).toBe(201); + expect(mockValidatePluginSandboxProviderConfig).toHaveBeenCalledWith({ + db: expect.anything(), + workerManager: pluginWorkerManager, + provider: "fake-plugin", + config: { + image: "fake:test", + timeoutMs: 450000, + reuseLease: true, + }, + }); expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", { name: "Fake plugin Sandbox", driver: "sandbox", @@ -565,6 +637,101 @@ describe("environment routes", () => { expect(mockSecretService.create).not.toHaveBeenCalled(); }); + it("creates a schema-driven sandbox environment with secret-ref fields persisted as secrets", async () => { + const environment = { + ...createEnvironment(), + id: "env-sandbox-secure-plugin", + name: "Secure Sandbox", + driver: "sandbox" as const, + config: { + provider: "secure-plugin", + template: "base", + apiKey: "11111111-1111-1111-1111-111111111111", + timeoutMs: 450000, + reuseLease: true, + }, + }; + mockEnvironmentService.create.mockResolvedValue(environment); + mockValidatePluginSandboxProviderConfig.mockResolvedValue({ + normalizedConfig: { + template: "base", + apiKey: "test-provider-key", + timeoutMs: 450000, + reuseLease: true, + }, + pluginId: "plugin-secure", + pluginKey: "acme.secure-sandbox-provider", + driver: { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, + }, + }, + }, + }); + const pluginWorkerManager = {}; + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }, { pluginWorkerManager }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Secure Sandbox", + driver: "sandbox", + config: { + provider: "secure-plugin", + template: " base ", + apiKey: " test-provider-key ", + timeoutMs: "450000", + reuseLease: true, + }, + }); + + expect(res.status).toBe(201); + expect(mockValidatePluginSandboxProviderConfig).toHaveBeenCalledWith({ + db: expect.anything(), + workerManager: pluginWorkerManager, + provider: "secure-plugin", + config: { + template: " base ", + apiKey: " test-provider-key ", + timeoutMs: 450000, + reuseLease: true, + }, + }); + expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", { + name: "Secure Sandbox", + driver: "sandbox", + status: "active", + config: { + provider: "secure-plugin", + template: "base", + apiKey: "11111111-1111-1111-1111-111111111111", + timeoutMs: 450000, + reuseLease: true, + }, + }); + expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][1])).not.toContain("test-provider-key"); + expect(mockSecretService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + provider: "local_encrypted", + value: "test-provider-key", + }), + expect.any(Object), + ); + }); + it("validates plugin environment config through the plugin driver host", async () => { const environment = { ...createEnvironment(), @@ -997,12 +1164,13 @@ describe("environment routes", () => { summary: "Fake plugin sandbox provider is ready.", details: { provider: "fake-plugin" }, }); + const pluginWorkerManager = {}; const app = createApp({ type: "board", userId: "user-1", source: "local_implicit", runId: "run-1", - }); + }, { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments/probe-config") @@ -1031,7 +1199,7 @@ describe("environment routes", () => { }), }), expect.objectContaining({ - pluginWorkerManager: undefined, + pluginWorkerManager, resolvedConfig: expect.objectContaining({ driver: "sandbox", }), diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 9086e537..7b96e255 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -56,6 +56,7 @@ describe("findReusableSandboxLeaseId", () => { metadata: { provider: "fake-plugin", image: "template-a", + timeoutMs: 300000, reuseLease: true, }, }, @@ -64,13 +65,14 @@ describe("findReusableSandboxLeaseId", () => { metadata: { provider: "fake-plugin", image: "template-b", + timeoutMs: 300000, reuseLease: true, }, }, ], }); - expect(selected).toBe("sandbox-template-a"); + expect(selected).toBe("sandbox-template-b"); }); it("requires image identity for reusable fake sandbox leases", () => { @@ -476,7 +478,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string, params: any) => { - expect(params.config).toEqual(expect.objectContaining(fakePluginConfig)); + expect(params.config).toEqual(expect.objectContaining({ + image: "fake:test", + timeoutMs: 1234, + reuseLease: false, + })); + expect(params.config).not.toHaveProperty("provider"); if (method === "environmentAcquireLease") { return { providerLeaseId: "sandbox-1", @@ -499,12 +506,17 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }; } if (method === "environmentReleaseLease") { - expect(params.config).toEqual(fakePluginConfig); + expect(params.config).toEqual({ + image: "fake:test", + timeoutMs: 1234, + reuseLease: false, + }); expect(params.config).not.toHaveProperty("driver"); expect(params.config).not.toHaveProperty("executionWorkspaceMode"); expect(params.config).not.toHaveProperty("pluginId"); expect(params.config).not.toHaveProperty("pluginKey"); expect(params.config).not.toHaveProperty("providerMetadata"); + expect(params.config).not.toHaveProperty("provider"); expect(params.config).not.toHaveProperty("sandboxProviderPlugin"); return undefined; } @@ -543,6 +555,270 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything()); }); + it("uses resolved secret-ref config for plugin-backed sandbox execute and release", async () => { + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const apiSecret = await secretService(db).create(companyId, { + name: `secure-plugin-api-key-${randomUUID()}`, + provider: "local_encrypted", + value: "resolved-provider-key", + }); + const providerConfig = { + provider: "secure-plugin", + template: "base", + apiKey: apiSecret.id, + timeoutMs: 1234, + reuseLease: false, + }; + const environment = { + ...baseEnvironment, + name: "Secure Plugin Sandbox", + driver: "sandbox", + config: providerConfig, + }; + await environmentService(db).update(environment.id, { + driver: "sandbox", + name: environment.name, + config: providerConfig, + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "acme.secure-sandbox-provider", + packageName: "@acme/secure-sandbox-provider", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.secure-sandbox-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, + }, + }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string, params: any) => { + expect(params.config.apiKey).toBe("resolved-provider-key"); + expect(params.config).not.toHaveProperty("provider"); + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "sandbox-1", + metadata: { + provider: "secure-plugin", + template: "base", + apiKey: "resolved-provider-key", + timeoutMs: 1234, + reuseLease: false, + sandboxId: "sandbox-1", + remoteCwd: "/workspace", + }, + }; + } + if (method === "environmentExecute") { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "ok\n", + stderr: "", + }; + } + if (method === "environmentReleaseLease") { + return undefined; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const acquired = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + heartbeatRunId: runId, + persistedExecutionWorkspace: null, + }); + expect(acquired.lease.metadata).toMatchObject({ + provider: "secure-plugin", + template: "base", + apiKey: apiSecret.id, + timeoutMs: 1234, + sandboxId: "sandbox-1", + }); + const executed = await runtimeWithPlugin.execute({ + environment, + lease: acquired.lease, + command: "printf", + args: ["ok"], + cwd: "/workspace", + env: {}, + timeoutMs: 1000, + }); + + await environmentService(db).update(environment.id, { + driver: "local", + config: {}, + }); + const released = await runtimeWithPlugin.releaseRunLeases(runId); + + expect(executed.stdout).toBe("ok\n"); + expect(released).toHaveLength(1); + expect(released[0]?.lease.status).toBe("released"); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.objectContaining({ + config: expect.objectContaining({ + apiKey: "resolved-provider-key", + }), + })); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.objectContaining({ + config: expect.objectContaining({ + apiKey: "resolved-provider-key", + }), + })); + }); + + it("falls back to acquire when plugin-backed sandbox lease resume throws", async () => { + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const providerConfig = { + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + }; + const environment = { + ...baseEnvironment, + name: "Reusable Plugin Sandbox", + driver: "sandbox", + config: providerConfig, + }; + await environmentService(db).update(environment.id, { + driver: "sandbox", + name: environment.name, + config: providerConfig, + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "acme.fake-sandbox-provider", + packageName: "@acme/fake-sandbox-provider", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.fake-sandbox-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, + }, + }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + await environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "stale-plugin-lease", + metadata: { + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + }, + }); + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentResumeLease") { + throw new Error("stale sandbox"); + } + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "fresh-plugin-lease", + metadata: { + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + remoteCwd: "/workspace", + }, + }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const acquired = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + heartbeatRunId: runId, + persistedExecutionWorkspace: null, + }); + + expect(acquired.lease.providerLeaseId).toBe("fresh-plugin-lease"); + expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentResumeLease", expect.objectContaining({ + driverKey: "fake-plugin", + providerLeaseId: "stale-plugin-lease", + })); + expect(workerManager.call).toHaveBeenNthCalledWith(2, pluginId, "environmentAcquireLease", expect.objectContaining({ + driverKey: "fake-plugin", + config: { + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + }, + runId, + })); + }); + it("releases a sandbox run lease from metadata after the environment config changes", async () => { const { companyId, environment, runId } = await seedEnvironment({ driver: "sandbox", diff --git a/server/src/__tests__/json-schema-secret-refs.test.ts b/server/src/__tests__/json-schema-secret-refs.test.ts new file mode 100644 index 00000000..f9394c5c --- /dev/null +++ b/server/src/__tests__/json-schema-secret-refs.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { collectSecretRefPaths } from "../services/json-schema-secret-refs.ts"; + +describe("collectSecretRefPaths", () => { + it("collects nested secret-ref paths from object properties", () => { + expect(Array.from(collectSecretRefPaths({ + type: "object", + properties: { + credentials: { + type: "object", + properties: { + apiKey: { type: "string", format: "secret-ref" }, + }, + }, + }, + }))).toEqual(["credentials.apiKey"]); + }); + + it("collects secret-ref paths from JSON Schema composition keywords", () => { + expect(Array.from(collectSecretRefPaths({ + type: "object", + allOf: [ + { + properties: { + apiKey: { type: "string", format: "secret-ref" }, + }, + }, + { + properties: { + nested: { + oneOf: [ + { + properties: { + token: { type: "string", format: "secret-ref" }, + }, + }, + ], + }, + }, + }, + ], + })).sort()).toEqual(["apiKey", "nested.token"]); + }); +}); diff --git a/server/src/__tests__/sandbox-provider-runtime.test.ts b/server/src/__tests__/sandbox-provider-runtime.test.ts index 6ad6ee11..68104d46 100644 --- a/server/src/__tests__/sandbox-provider-runtime.test.ts +++ b/server/src/__tests__/sandbox-provider-runtime.test.ts @@ -109,6 +109,41 @@ describe("sandbox provider runtime", () => { ).toBe("sandbox-image-b"); }); + it("matches reusable plugin leases by persisted config fields", () => { + expect( + findReusableSandboxProviderLeaseId({ + config: { + provider: "secure-plugin", + template: "template-b", + apiKey: "22222222-2222-2222-2222-222222222222", + timeoutMs: 300000, + reuseLease: true, + }, + leases: [ + { + providerLeaseId: "sandbox-template-a", + metadata: { + provider: "secure-plugin", + template: "template-a", + apiKey: "11111111-1111-1111-1111-111111111111", + reuseLease: true, + }, + }, + { + providerLeaseId: "sandbox-template-b", + metadata: { + provider: "secure-plugin", + template: "template-b", + apiKey: "22222222-2222-2222-2222-222222222222", + timeoutMs: 300000, + reuseLease: true, + }, + }, + ], + }), + ).toBe("sandbox-template-b"); + }); + it("reconstructs fake sandbox config from lease metadata for later release", () => { const metadata = { provider: "fake", @@ -146,6 +181,31 @@ describe("sandbox provider runtime", () => { }); }); + it("reconstructs plugin-backed secret-ref config from lease metadata for later release", () => { + expect(sandboxConfigFromLeaseMetadata({ + metadata: { + provider: "secure-plugin", + template: "paperclip-template", + }, + })).toBeNull(); + + expect(sandboxConfigFromLeaseMetadataLoose({ + metadata: { + provider: "secure-plugin", + template: "paperclip-template", + timeoutMs: 120000, + reuseLease: true, + apiKey: "11111111-1111-1111-1111-111111111111", + }, + })).toEqual({ + provider: "secure-plugin", + template: "paperclip-template", + apiKey: "11111111-1111-1111-1111-111111111111", + timeoutMs: 120000, + reuseLease: true, + }); + }); + it("releases fake leases without external side effects", async () => { await expect(releaseSandboxProviderLease({ config: { diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index e9ec7dd8..fd7976ca 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -184,6 +184,7 @@ export function environmentRoutes( source: "plugin" as const, pluginKey: driver.pluginKey, pluginId: driver.pluginId, + configSchema: driver.configSchema, }, ])), }, @@ -409,9 +410,11 @@ export function environmentRoutes( const companyId = req.params.companyId as string; await assertCanMutateEnvironments(req, companyId); const actor = getActorInfo(req); - const normalizedConfig = normalizeEnvironmentConfigForProbe({ + const normalizedConfig = await normalizeEnvironmentConfigForProbe({ + db, driver: req.body.driver, config: req.body.config, + pluginWorkerManager: options.pluginWorkerManager, }); const environment = { id: "unsaved", diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 4e70776e..2c9bdf41 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -6,16 +6,26 @@ import type { EnvironmentDriver, FakeSandboxEnvironmentConfig, LocalEnvironmentConfig, - PluginSandboxEnvironmentConfig, PluginEnvironmentConfig, + PluginSandboxEnvironmentConfig, SandboxEnvironmentConfig, SshEnvironmentConfig, } from "@paperclipai/shared"; import { unprocessable } from "../errors.js"; import { parseObject } from "../adapters/utils.js"; import { secretService } from "./secrets.js"; -import { validatePluginEnvironmentDriverConfig } from "./plugin-environment-driver.js"; +import { + resolvePluginSandboxProviderDriverByKey, + validatePluginEnvironmentDriverConfig, + validatePluginSandboxProviderConfig, +} from "./plugin-environment-driver.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { + collectSecretRefPaths, + isUuidSecretRef, + readConfigValueAtPath, + writeConfigValueAtPath, +} from "./json-schema-secret-refs.js"; const secretRefSchema = z.object({ type: z.literal("secret_ref"), @@ -43,6 +53,17 @@ const sshEnvironmentConfigSchema = z.object({ strictHostKeyChecking: z.boolean().optional().default(true), }).strict(); +const sshEnvironmentConfigProbeSchema = sshEnvironmentConfigSchema.extend({ + privateKey: z + .string() + .trim() + .optional() + .nullable() + .transform((value) => (value && value.length > 0 ? value : null)), +}).strict(); + +const sshEnvironmentConfigPersistenceSchema = sshEnvironmentConfigProbeSchema; + const fakeSandboxEnvironmentConfigSchema = z.object({ provider: z.literal("fake").default("fake"), image: z @@ -59,10 +80,7 @@ const pluginSandboxProviderKeySchema = z.string() .regex( /^[a-z0-9][a-z0-9._-]*$/, "Sandbox provider key must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores", - ) - .refine((value) => value !== "fake", { - message: "Built-in sandbox providers must use their dedicated config schema.", - }); + ); const pluginSandboxEnvironmentConfigSchema = z.object({ provider: pluginSandboxProviderKeySchema, @@ -70,8 +88,6 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ reuseLease: z.boolean().optional().default(false), }).catchall(z.unknown()); -type SandboxConfigSchemaMode = "stored" | "probe" | "persistence"; - const pluginEnvironmentConfigSchema = z.object({ pluginKey: z.string().min(1), driverKey: z.string().min(1).regex( @@ -99,7 +115,6 @@ function getSandboxProvider(raw: Record) { function parseSandboxEnvironmentConfig( input: Record | null | undefined, - mode: SandboxConfigSchemaMode, ) { const raw = parseObject(input); const provider = getSandboxProvider(raw); @@ -117,16 +132,19 @@ function parseSandboxEnvironmentConfig( : ({ success: false as const, error: parsed.error }); } -const sshEnvironmentConfigProbeSchema = sshEnvironmentConfigSchema.extend({ - privateKey: z - .string() - .trim() - .optional() - .nullable() - .transform((value) => (value && value.length > 0 ? value : null)), -}).strict(); - -const sshEnvironmentConfigPersistenceSchema = sshEnvironmentConfigProbeSchema; +async function getSandboxProviderConfigSchema( + db: Db, + provider: string, +): Promise | null> { + const resolved = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: provider, + }); + const schema = resolved?.driver.configSchema; + return schema && typeof schema === "object" && !Array.isArray(schema) + ? schema as Record + : null; +} function secretName(input: { environmentName: string; @@ -167,6 +185,69 @@ async function createEnvironmentSecret(input: { }; } +async function persistConfigSecretRefs(input: { + db: Db; + companyId: string; + environmentName: string; + driver: EnvironmentDriver; + config: Record; + schema: Record | null; + actor?: { userId?: string | null; agentId?: string | null }; +}): Promise> { + let nextConfig = { ...input.config }; + for (const path of collectSecretRefPaths(input.schema)) { + const rawValue = readConfigValueAtPath(nextConfig, path); + if (typeof rawValue !== "string") continue; + const trimmed = rawValue.trim(); + if (trimmed.length === 0) { + nextConfig = writeConfigValueAtPath(nextConfig, path, undefined); + continue; + } + if (isUuidSecretRef(trimmed)) { + nextConfig = writeConfigValueAtPath(nextConfig, path, trimmed); + continue; + } + const created = await createEnvironmentSecret({ + db: input.db, + companyId: input.companyId, + environmentName: input.environmentName, + driver: input.driver, + field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(), + value: trimmed, + actor: input.actor, + }); + nextConfig = writeConfigValueAtPath(nextConfig, path, created.secretId); + } + return nextConfig; +} + +async function resolveConfigSecretRefsForRuntime(input: { + db: Db; + companyId: string; + config: Record; + schema: Record | null; +}): Promise> { + const secrets = secretService(input.db); + let nextConfig = { ...input.config }; + for (const path of collectSecretRefPaths(input.schema)) { + const current = readConfigValueAtPath(nextConfig, path); + if (typeof current !== "string") continue; + const trimmed = current.trim(); + if (!isUuidSecretRef(trimmed)) continue; + nextConfig = writeConfigValueAtPath( + nextConfig, + path, + await secrets.resolveSecretValue(input.companyId, trimmed, "latest"), + ); + } + return nextConfig; +} + +export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): Record { + const { provider: _provider, ...driverConfig } = config as Record; + return driverConfig; +} + export function normalizeEnvironmentConfig(input: { driver: EnvironmentDriver; config: Record | null | undefined; @@ -186,7 +267,7 @@ export function normalizeEnvironmentConfig(input: { } if (input.driver === "sandbox") { - const parsed = parseSandboxEnvironmentConfig(input.config, "stored"); + const parsed = parseSandboxEnvironmentConfig(input.config); if (!parsed.success) { throw unprocessable(toErrorMessage(parsed.error), { issues: parsed.error.issues, @@ -209,9 +290,11 @@ export function normalizeEnvironmentConfig(input: { } export function normalizeEnvironmentConfigForProbe(input: { + db: Db; driver: EnvironmentDriver; config: Record | null | undefined; -}): Record { + pluginWorkerManager?: PluginWorkerManager; +}): Promise> | Record { if (input.driver === "ssh") { const parsed = sshEnvironmentConfigProbeSchema.safeParse(parseObject(input.config)); if (!parsed.success) { @@ -223,16 +306,33 @@ export function normalizeEnvironmentConfigForProbe(input: { } if (input.driver === "sandbox") { - const parsed = parseSandboxEnvironmentConfig(input.config, "probe"); + const parsed = parseSandboxEnvironmentConfig(input.config); if (!parsed.success) { throw unprocessable(toErrorMessage(parsed.error), { issues: parsed.error.issues, }); } - return parsed.data; + if (parsed.data.provider === "fake") { + return parsed.data; + } + if (!input.pluginWorkerManager) { + throw unprocessable("Sandbox provider config validation requires a running plugin worker manager."); + } + return validatePluginSandboxProviderConfig({ + db: input.db, + workerManager: input.pluginWorkerManager, + provider: parsed.data.provider, + config: stripSandboxProviderEnvelope(parsed.data), + }).then((validated) => ({ + provider: parsed.data.provider, + ...validated.normalizedConfig, + })); } - return normalizeEnvironmentConfig(input); + return normalizeEnvironmentConfig({ + driver: input.driver, + config: input.config, + }); } export async function normalizeEnvironmentConfigForPersistence(input: { @@ -279,19 +379,41 @@ export async function normalizeEnvironmentConfigForPersistence(input: { } if (input.driver === "sandbox") { - const parsed = parseSandboxEnvironmentConfig(input.config, "persistence"); + const parsed = parseSandboxEnvironmentConfig(input.config); if (!parsed.success) { throw unprocessable(toErrorMessage(parsed.error), { issues: parsed.error.issues, }); } - const sandboxConfig = parsed.data; - if (sandboxConfig.provider === "fake") { + if (parsed.data.provider === "fake") { throw unprocessable( "Built-in fake sandbox environments are reserved for internal probes and cannot be saved.", ); } - return { ...(sandboxConfig as PluginSandboxEnvironmentConfig) }; + if (!input.pluginWorkerManager) { + throw unprocessable("Sandbox provider config validation requires a running plugin worker manager."); + } + const validated = await validatePluginSandboxProviderConfig({ + db: input.db, + workerManager: input.pluginWorkerManager, + provider: parsed.data.provider, + config: stripSandboxProviderEnvelope(parsed.data), + }); + return await persistConfigSecretRefs({ + db: input.db, + companyId: input.companyId, + environmentName: input.environmentName, + driver: input.driver, + config: { + provider: parsed.data.provider, + ...validated.normalizedConfig, + }, + schema: + validated.driver.configSchema && typeof validated.driver.configSchema === "object" && !Array.isArray(validated.driver.configSchema) + ? validated.driver.configSchema as Record + : null, + actor: input.actor, + }); } if (input.driver === "plugin") { @@ -339,6 +461,18 @@ export async function resolveEnvironmentDriverConfigForRuntime( }; } + if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") { + return { + driver: "sandbox", + config: await resolveConfigSecretRefsForRuntime({ + db, + companyId, + config: parsed.config as Record, + schema: await getSandboxProviderConfigSchema(db, parsed.config.provider), + }) as SandboxEnvironmentConfig, + }; + } + return parsed; } @@ -370,7 +504,7 @@ export function parseEnvironmentDriverConfig( } if (environment.driver === "sandbox") { - const parsed = parseSandboxEnvironmentConfig(environment.config, "stored"); + const parsed = parseSandboxEnvironmentConfig(environment.config); if (!parsed.success) { throw parsed.error; } diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 021025a4..b403d5f9 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -16,7 +16,11 @@ import type { } from "@paperclipai/plugin-sdk"; import { ensureSshWorkspaceReady, findReachablePaperclipApiUrlOverSsh } from "@paperclipai/adapter-utils/ssh"; import { environmentService } from "./environments.js"; -import { parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; +import { + parseEnvironmentDriverConfig, + resolveEnvironmentDriverConfigForRuntime, + stripSandboxProviderEnvelope, +} from "./environment-config.js"; import { acquireSandboxProviderLease, findReusableSandboxProviderLeaseId, @@ -31,8 +35,10 @@ import { destroyPluginEnvironmentLease, executePluginEnvironmentCommand, realizePluginEnvironmentWorkspace, + resolvePluginSandboxProviderDriverByKey, resumePluginEnvironmentLease, } from "./plugin-environment-driver.js"; +import { collectSecretRefPaths } from "./json-schema-secret-refs.js"; import { buildWorkspaceRealizationRecordFromDriverInput } from "./workspace-realization.js"; export function buildEnvironmentLeaseContext(input: { @@ -44,6 +50,53 @@ export function buildEnvironmentLeaseContext(input: { }; } +function stripSecretRefValuesFromPluginLeaseMetadata(input: { + metadata: Record | null | undefined; + schema: Record | null | undefined; +}): Record { + const sanitized = structuredClone(input.metadata ?? {}) as Record; + + for (const path of collectSecretRefPaths(input.schema)) { + const keys = path.split("."); + const parents: Array<{ container: Record; key: string }> = []; + let cursor: Record | null = sanitized; + + for (let index = 0; index < keys.length - 1; index += 1) { + const key = keys[index]!; + const next = cursor?.[key]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + cursor = null; + break; + } + parents.push({ container: cursor, key }); + cursor = next as Record; + } + + if (!cursor) continue; + + const leafKey = keys[keys.length - 1]!; + if (!Object.prototype.hasOwnProperty.call(cursor, leafKey)) continue; + delete cursor[leafKey]; + + for (let index = parents.length - 1; index >= 0; index -= 1) { + const { container, key } = parents[index]!; + const value = container[key]; + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value as Record).length === 0 + ) { + delete container[key]; + } else { + break; + } + } + } + + return sanitized; +} + export interface EnvironmentDriverAcquireInput { companyId: string; environment: Environment; @@ -238,33 +291,6 @@ function createSandboxEnvironmentDriver( pluginWorkerManager?: PluginWorkerManager, ): EnvironmentRuntimeDriver { const environmentsSvc = environmentService(db); - const pluginRegistry = pluginRegistryService(db); - - /** - * Resolve a sandbox provider plugin by looking up a plugin whose manifest - * declares an environment driver with a matching driverKey. Returns null - * if no matching plugin is found or the worker isn't running. - */ - async function resolvePluginForProvider( - provider: string, - ): Promise<{ pluginId: string; pluginKey: string } | null> { - if (!pluginWorkerManager) return null; - const plugins = await pluginRegistry.list(); - for (const plugin of plugins) { - if (plugin.status !== "ready") continue; - const drivers = plugin.manifestJson.environmentDrivers ?? []; - for (const driver of drivers) { - if ( - driver.driverKey === provider && - driver.kind === "sandbox_provider" && - pluginWorkerManager.isRunning(plugin.id) - ) { - return { pluginId: plugin.id, pluginKey: plugin.pluginKey }; - } - } - } - return null; - } async function resolvePluginSandboxRuntimeConfig(input: { environment: Environment; @@ -308,29 +334,64 @@ function createSandboxEnvironmentDriver( driver: "sandbox", async acquireRunLease(input) { + const storedParsed = parseEnvironmentDriverConfig(input.environment); const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment); - if (parsed.driver !== "sandbox") { + if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") { throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`); } // Check if this provider should be handled by a plugin. if (!isBuiltinSandboxProvider(parsed.config.provider)) { - const pluginProvider = await resolvePluginForProvider(parsed.config.provider); + const pluginProvider = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: parsed.config.provider, + workerManager: pluginWorkerManager, + requireRunning: true, + }); if (!pluginProvider || !pluginWorkerManager) { throw new Error( `Sandbox provider "${parsed.config.provider}" is not registered as a built-in provider and no matching plugin is available.`, ); } - // Delegate to the plugin worker for lease acquisition. - const providerLease = await pluginWorkerManager.call( - pluginProvider.pluginId, + const workerConfig = stripSandboxProviderEnvelope(parsed.config); + const storedConfig = storedParsed.config; + const existingLeases = parsed.config.reuseLease + ? await environmentsSvc.listLeases(input.environment.id) + : []; + const reusableProviderLeaseId = parsed.config.reuseLease + ? findReusableSandboxLeaseId({ config: storedConfig, leases: existingLeases }) + : null; + const reusableLease = reusableProviderLeaseId + ? existingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId) + : null; + + const providerLease = reusableLease?.providerLeaseId + ? await pluginWorkerManager.call( + pluginProvider.plugin.id, + "environmentResumeLease", + { + driverKey: parsed.config.provider, + companyId: input.companyId, + environmentId: input.environment.id, + config: workerConfig, + providerLeaseId: reusableLease.providerLeaseId, + leaseMetadata: reusableLease.metadata ?? undefined, + }, + ).then((resumed) => + typeof resumed.providerLeaseId === "string" && resumed.providerLeaseId.length > 0 + ? resumed + : null, + ).catch(() => null) + : null; + const acquiredLease = providerLease ?? await pluginWorkerManager.call( + pluginProvider.plugin.id, "environmentAcquireLease", { driverKey: parsed.config.provider, companyId: input.companyId, environmentId: input.environment.id, - config: parsed.config as unknown as Record, + config: workerConfig, runId: input.heartbeatRunId, workspaceMode: input.executionWorkspaceMode ?? undefined, }, @@ -348,16 +409,19 @@ function createSandboxEnvironmentDriver( heartbeatRunId: input.heartbeatRunId, leasePolicy: resolvedLeasePolicy, provider: parsed.config.provider, - providerLeaseId: providerLease.providerLeaseId, - expiresAt: providerLease.expiresAt ? new Date(providerLease.expiresAt) : undefined, + providerLeaseId: acquiredLease.providerLeaseId, + expiresAt: acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined, metadata: { driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, - pluginId: pluginProvider.pluginId, - pluginKey: pluginProvider.pluginKey, + pluginId: pluginProvider.plugin.id, + pluginKey: pluginProvider.plugin.pluginKey, sandboxProviderPlugin: true, - ...sandboxConfigForLeaseMetadata(parsed.config), - ...(providerLease.metadata ?? {}), + ...sandboxConfigForLeaseMetadata(storedConfig), + ...stripSecretRefValuesFromPluginLeaseMetadata({ + metadata: acquiredLease.metadata, + schema: pluginProvider.driver.configSchema as Record | null | undefined, + }), }, }); } @@ -462,7 +526,7 @@ function createSandboxEnvironmentDriver( driverKey: providerKey, companyId: input.lease.companyId, environmentId: input.environment.id, - config, + config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig), lease: { providerLeaseId: input.lease.providerLeaseId, metadata: input.lease.metadata ?? undefined, @@ -505,7 +569,7 @@ function createSandboxEnvironmentDriver( driverKey: providerKey, companyId: input.lease.companyId, environmentId: input.environment.id, - config, + config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig), lease: { providerLeaseId: input.lease.providerLeaseId, metadata: input.lease.metadata ?? undefined, @@ -543,7 +607,7 @@ function createSandboxEnvironmentDriver( driverKey: providerKey, companyId: input.lease.companyId, environmentId: input.environment.id, - config, + config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig), providerLeaseId: input.lease.providerLeaseId, leaseMetadata: metadata, }); diff --git a/server/src/services/json-schema-secret-refs.ts b/server/src/services/json-schema-secret-refs.ts new file mode 100644 index 00000000..91b3bcc2 --- /dev/null +++ b/server/src/services/json-schema-secret-refs.ts @@ -0,0 +1,79 @@ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isUuidSecretRef(value: string): boolean { + return UUID_RE.test(value); +} + +export function collectSecretRefPaths( + schema: Record | null | undefined, +): Set { + const paths = new Set(); + if (!schema || typeof schema !== "object") return paths; + + function walk(node: Record, prefix: string): void { + for (const keyword of ["allOf", "anyOf", "oneOf"] as const) { + const branches = node[keyword]; + if (!Array.isArray(branches)) continue; + for (const branch of branches) { + if (!branch || typeof branch !== "object" || Array.isArray(branch)) continue; + walk(branch as Record, prefix); + } + } + + const properties = node.properties as Record> | undefined; + if (!properties || typeof properties !== "object") return; + for (const [key, propertySchema] of Object.entries(properties)) { + if (!propertySchema || typeof propertySchema !== "object") continue; + const path = prefix ? `${prefix}.${key}` : key; + if (propertySchema.format === "secret-ref") { + paths.add(path); + } + walk(propertySchema, path); + } + } + + walk(schema, ""); + return paths; +} + +export function readConfigValueAtPath( + config: Record, + dotPath: string, +): unknown { + let current: unknown = config; + for (const key of dotPath.split(".")) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + +export function writeConfigValueAtPath( + config: Record, + dotPath: string, + value: unknown, +): Record { + const result = structuredClone(config) as Record; + const keys = dotPath.split("."); + let cursor: Record = result; + + for (let index = 0; index < keys.length - 1; index += 1) { + const key = keys[index]!; + const next = cursor[key]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + cursor[key] = {}; + } + cursor = cursor[key] as Record; + } + + const leafKey = keys[keys.length - 1]!; + if (value === undefined) { + delete cursor[leafKey]; + } else { + cursor[leafKey] = value; + } + return result; +} diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 215beb64..2b267eb5 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -1,5 +1,9 @@ import type { Db } from "@paperclipai/db"; -import type { EnvironmentProbeResult, PluginEnvironmentConfig } from "@paperclipai/shared"; +import type { + EnvironmentProbeResult, + PluginEnvironmentConfig, + PluginEnvironmentDriverDeclaration, +} from "@paperclipai/shared"; import type { PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, @@ -42,15 +46,31 @@ export async function resolvePluginEnvironmentDriverByKey(input: { workerManager: PluginWorkerManager; driverKey: string; }) { + return await resolvePluginSandboxProviderDriverByKey({ + db: input.db, + driverKey: input.driverKey, + workerManager: input.workerManager, + requireRunning: true, + }); +} + +export async function resolvePluginSandboxProviderDriverByKey(input: { + db: Db; + driverKey: string; + workerManager?: PluginWorkerManager; + requireRunning?: boolean; +}): Promise<{ plugin: Awaited["list"]>>[number]; driver: PluginEnvironmentDriverDeclaration } | null> { const pluginRegistry = pluginRegistryService(input.db); const plugins = await pluginRegistry.list(); for (const plugin of plugins) { - if (plugin.status !== "ready") continue; const driver = plugin.manifestJson.environmentDrivers?.find( (candidate) => candidate.driverKey === input.driverKey && candidate.kind === "sandbox_provider", - ); + ) as PluginEnvironmentDriverDeclaration | undefined; if (!driver) continue; - if (!input.workerManager.isRunning(plugin.id)) continue; + if (input.requireRunning) { + if (plugin.status !== "ready") continue; + if (!input.workerManager?.isRunning(plugin.id)) continue; + } return { plugin, driver }; } return null; @@ -73,10 +93,55 @@ export async function listReadyPluginEnvironmentDrivers(input: { driverKey: driver.driverKey, displayName: driver.displayName, description: driver.description, + configSchema: driver.configSchema, })); }); } +export async function validatePluginSandboxProviderConfig(input: { + db: Db; + workerManager: PluginWorkerManager; + provider: string; + config: Record; +}): Promise<{ + normalizedConfig: Record; + pluginId: string; + pluginKey: string; + driver: PluginEnvironmentDriverDeclaration; +}> { + const resolved = await resolvePluginSandboxProviderDriverByKey({ + db: input.db, + driverKey: input.provider, + workerManager: input.workerManager, + requireRunning: true, + }); + if (!resolved) { + throw unprocessable(`Sandbox provider "${input.provider}" is not installed or its plugin worker is not running.`); + } + + const result = await input.workerManager.call(resolved.plugin.id, "environmentValidateConfig", { + driverKey: input.provider, + config: input.config, + }); + + if (!result.ok) { + throw unprocessable( + result.errors?.[0] ?? `Sandbox provider "${input.provider}" rejected its config.`, + { + errors: result.errors ?? [], + warnings: result.warnings ?? [], + }, + ); + } + + return { + normalizedConfig: result.normalizedConfig ?? input.config, + pluginId: resolved.plugin.id, + pluginKey: resolved.plugin.pluginKey, + driver: resolved.driver, + }; +} + export async function validatePluginEnvironmentDriverConfig(input: { db: Db; workerManager: PluginWorkerManager; @@ -156,11 +221,12 @@ export async function probePluginSandboxProviderDriver(input: { }; } + const { provider: _provider, ...driverConfig } = input.config; const result = await input.workerManager.call(resolved.plugin.id, "environmentProbe", { driverKey: input.provider, companyId: input.companyId, environmentId: input.environmentId, - config: input.config, + config: driverConfig, }); return { diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index 29d3a2d4..b80ae187 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -39,6 +39,11 @@ import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipa import type { SecretProvider } from "@paperclipai/shared"; import { getSecretProvider } from "../secrets/provider-registry.js"; import { pluginRegistryService } from "./plugin-registry.js"; +import { + collectSecretRefPaths, + isUuidSecretRef, + readConfigValueAtPath, +} from "./json-schema-secret-refs.js"; // --------------------------------------------------------------------------- // Error helpers @@ -70,48 +75,6 @@ function invalidSecretRef(secretRef: string): Error { // Validation // --------------------------------------------------------------------------- -/** UUID v4 regex for validating secretRef format. */ -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** - * Check whether a secretRef looks like a valid UUID. - */ -function isUuid(value: string): boolean { - return UUID_RE.test(value); -} - -/** - * Collect the property paths (dot-separated keys) whose schema node declares - * `format: "secret-ref"`. Only top-level and nested `properties` are walked — - * this mirrors the flat/nested object shapes that `JsonSchemaForm` renders. - */ -function collectSecretRefPaths( - schema: Record | null | undefined, -): Set { - const paths = new Set(); - if (!schema || typeof schema !== "object") return paths; - - function walk(node: Record, prefix: string): void { - const props = node.properties as Record> | undefined; - if (!props || typeof props !== "object") return; - for (const [key, propSchema] of Object.entries(props)) { - if (!propSchema || typeof propSchema !== "object") continue; - const path = prefix ? `${prefix}.${key}` : key; - if (propSchema.format === "secret-ref") { - paths.add(path); - } - // Recurse into nested object schemas - if (propSchema.type === "object") { - walk(propSchema, path); - } - } - } - - walk(schema, ""); - return paths; -} - /** * Extract secret reference UUIDs from a plugin's configJson, scoped to only * the fields annotated with `format: "secret-ref"` in the schema. @@ -131,13 +94,8 @@ export function extractSecretRefsFromConfig( // If schema declares secret-ref paths, extract only those values. if (secretPaths.size > 0) { for (const dotPath of secretPaths) { - const keys = dotPath.split("."); - let current: unknown = configJson; - for (const k of keys) { - if (current == null || typeof current !== "object") { current = undefined; break; } - current = (current as Record)[k]; - } - if (typeof current === "string" && isUuid(current)) { + const current = readConfigValueAtPath(configJson as Record, dotPath); + if (typeof current === "string" && isUuidSecretRef(current)) { refs.add(current); } } @@ -149,7 +107,7 @@ export function extractSecretRefsFromConfig( // instanceConfigSchema. function walkAll(value: unknown): void { if (typeof value === "string") { - if (isUuid(value)) refs.add(value); + if (isUuidSecretRef(value)) refs.add(value); } else if (Array.isArray(value)) { for (const item of value) walkAll(item); } else if (value !== null && typeof value === "object") { @@ -279,7 +237,7 @@ export function createPluginSecretsHandler( const trimmedRef = secretRef.trim(); - if (!isUuid(trimmedRef)) { + if (!isUuidSecretRef(trimmedRef)) { throw invalidSecretRef(trimmedRef); } diff --git a/server/src/services/sandbox-provider-runtime.ts b/server/src/services/sandbox-provider-runtime.ts index f673796c..42b48199 100644 --- a/server/src/services/sandbox-provider-runtime.ts +++ b/server/src/services/sandbox-provider-runtime.ts @@ -282,15 +282,13 @@ export function findReusableSandboxProviderLeaseId(input: { }): string | null { const provider = getSandboxProvider(input.config.provider); if (!provider) { - // For plugin-backed providers, reuse matching is handled by the plugin - // environment driver. Fall back to metadata-based matching. for (const lease of input.leases) { const metadata = lease.metadata ?? {}; if ( typeof lease.providerLeaseId === "string" && lease.providerLeaseId.length > 0 && metadata.provider === input.config.provider && - metadata.reuseLease === true + metadataMatchesPluginSandboxConfig(input.config, metadata) ) { return lease.providerLeaseId; } @@ -305,6 +303,21 @@ export function findReusableSandboxProviderLeaseId(input: { return null; } +function metadataMatchesPluginSandboxConfig( + config: SandboxEnvironmentConfig, + metadata: Record, +): boolean { + if (metadata.reuseLease !== true) return false; + for (const [key, value] of Object.entries(config)) { + if (key === "provider" || key === "reuseLease") continue; + if (value === undefined) continue; + if (JSON.stringify(metadata[key]) !== JSON.stringify(value)) { + return false; + } + } + return true; +} + export async function probeSandboxProvider( config: SandboxEnvironmentConfig, ): Promise { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index a63a3b4a..b34eee10 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -297,7 +297,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { [adapterType], ); const runnableEnvironments = useMemo( - () => environments.filter((environment) => supportedEnvironmentDrivers.has(environment.driver)), + () => environments.filter((environment) => { + if (!supportedEnvironmentDrivers.has(environment.driver)) return false; + if (environment.driver !== "sandbox") return true; + const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; + return provider !== null && provider !== "fake"; + }), [environments, supportedEnvironmentDrivers], ); diff --git a/ui/src/pages/CompanySettings.test.tsx b/ui/src/pages/CompanySettings.test.tsx index 18944d6f..3ab560b2 100644 --- a/ui/src/pages/CompanySettings.test.tsx +++ b/ui/src/pages/CompanySettings.test.tsx @@ -164,4 +164,87 @@ describe("CompanySettings", () => { root.unmount(); }); }); + + it("preserves sandbox config when re-selecting the same provider while editing", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockEnvironmentsApi.list.mockResolvedValue([ + { + id: "env-1", + companyId: "company-1", + name: "Secure Sandbox", + description: null, + driver: "sandbox", + status: "active", + config: { + provider: "secure-plugin", + template: "saved-template", + }, + metadata: null, + createdAt: new Date("2026-04-25T00:00:00.000Z"), + updatedAt: new Date("2026-04-25T00:00:00.000Z"), + }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue( + getEnvironmentCapabilities(AGENT_ADAPTER_TYPES, { + sandboxProviders: { + "secure-plugin": { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string", title: "Template" }, + }, + }, + }, + }, + }), + ); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const editButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Edit"); + expect(editButton).toBeTruthy(); + + await act(async () => { + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const providerSelect = Array.from(container.querySelectorAll("select")) + .find((select) => Array.from(select.options).some((option) => option.value === "secure-plugin")) as HTMLSelectElement | undefined; + expect(providerSelect).toBeTruthy(); + + await act(async () => { + providerSelect!.value = "secure-plugin"; + providerSelect!.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flushReact(); + + const templateInput = Array.from(container.querySelectorAll("input")) + .find((input) => (input as HTMLInputElement).value === "saved-template") as HTMLInputElement | undefined; + expect(templateInput?.value).toBe("saved-template"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 015aabda..63cc0394 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -5,6 +5,7 @@ import { getAdapterEnvironmentSupport, type Environment, type EnvironmentProbeResult, + type JsonSchema, } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -19,6 +20,7 @@ import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Settings, Check, Download, Upload } from "lucide-react"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; +import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm"; import { Field, ToggleField, @@ -45,12 +47,7 @@ type EnvironmentFormState = { sshKnownHosts: string; sshStrictHostKeyChecking: boolean; sandboxProvider: string; - sandboxImage: string; - sandboxTemplate: string; - sandboxApiKey: string; - sandboxApiKeySecretId: string; - sandboxTimeoutMs: string; - sandboxReuseLease: boolean; + sandboxConfig: Record; }; const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({ @@ -81,9 +78,7 @@ function buildEnvironmentPayload(form: EnvironmentFormState) { : form.driver === "sandbox" ? { provider: form.sandboxProvider.trim(), - image: form.sandboxImage.trim() || "ubuntu:24.04", - timeoutMs: Number.parseInt(form.sandboxTimeoutMs || "300000", 10) || 300000, - reuseLease: form.sandboxReuseLease, + ...form.sandboxConfig, } : {}, } as const; @@ -103,12 +98,7 @@ function createEmptyEnvironmentForm(): EnvironmentFormState { sshKnownHosts: "", sshStrictHostKeyChecking: true, sandboxProvider: "", - sandboxImage: "ubuntu:24.04", - sandboxTemplate: "base", - sandboxApiKey: "", - sandboxApiKeySecretId: "", - sandboxTimeoutMs: "300000", - sandboxReuseLease: false, + sandboxConfig: {}, }; } @@ -143,36 +133,31 @@ function readSshConfig(environment: Environment) { function readSandboxConfig(environment: Environment) { const config = environment.config ?? {}; + const { provider: rawProvider, ...providerConfig } = config; return { - provider: - typeof config.provider === "string" && config.provider.trim().length > 0 - ? config.provider + provider: typeof rawProvider === "string" && rawProvider.trim().length > 0 + ? rawProvider : "fake", - image: typeof config.image === "string" && config.image.trim().length > 0 - ? config.image - : "ubuntu:24.04", - template: - typeof config.template === "string" && config.template.trim().length > 0 - ? config.template - : "base", - apiKey: "", - apiKeySecretId: - config.apiKeySecretRef && - typeof config.apiKeySecretRef === "object" && - !Array.isArray(config.apiKeySecretRef) && - typeof (config.apiKeySecretRef as { secretId?: unknown }).secretId === "string" - ? String((config.apiKeySecretRef as { secretId: string }).secretId) - : "", - timeoutMs: - typeof config.timeoutMs === "number" - ? String(config.timeoutMs) - : typeof config.timeoutMs === "string" && config.timeoutMs.trim().length > 0 - ? config.timeoutMs - : "300000", - reuseLease: typeof config.reuseLease === "boolean" ? config.reuseLease : false, + config: providerConfig, }; } +function normalizeJsonSchema(schema: unknown): JsonSchema | null { + return schema && typeof schema === "object" && !Array.isArray(schema) + ? schema as JsonSchema + : null; +} + +function summarizeSandboxConfig(config: Record): string | null { + for (const key of ["template", "image", "region", "workspacePath"]) { + const value = config[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return null; +} + function SupportMark({ supported }: { supported: boolean }) { return supported ? ( @@ -525,12 +510,7 @@ export function CompanySettings() { description: environment.description ?? "", driver: "sandbox", sandboxProvider: sandbox.provider, - sandboxImage: sandbox.image, - sandboxTemplate: sandbox.template, - sandboxApiKey: sandbox.apiKey, - sandboxApiKeySecretId: sandbox.apiKeySecretId, - sandboxTimeoutMs: sandbox.timeoutMs, - sandboxReuseLease: sandbox.reuseLease, + sandboxConfig: sandbox.config, }); return; } @@ -553,6 +533,8 @@ export function CompanySettings() { .map(([provider, capability]) => ({ provider, displayName: capability.displayName || provider, + description: capability.description, + configSchema: normalizeJsonSchema(capability.configSchema), })) .sort((left, right) => left.displayName.localeCompare(right.displayName)); const sandboxCreationEnabled = discoveredPluginSandboxProviders.length > 0; @@ -563,21 +545,32 @@ export function CompanySettings() { !discoveredPluginSandboxProviders.some((provider) => provider.provider === environmentForm.sandboxProvider) ? [ ...discoveredPluginSandboxProviders, - { provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider }, + { provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider, description: undefined, configSchema: null }, ] : discoveredPluginSandboxProviders; + const selectedSandboxProvider = pluginSandboxProviders.find( + (provider) => provider.provider === environmentForm.sandboxProvider, + ) ?? null; + const selectedSandboxSchema = selectedSandboxProvider?.configSchema ?? null; + const sandboxConfigErrors = + environmentForm.driver === "sandbox" && selectedSandboxSchema + ? validateJsonSchemaForm(selectedSandboxSchema as any, environmentForm.sandboxConfig) + : {}; + useEffect(() => { if (environmentForm.driver !== "sandbox") return; if (environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake") return; const firstProvider = discoveredPluginSandboxProviders[0]?.provider; if (!firstProvider) return; + const firstSchema = discoveredPluginSandboxProviders[0]?.configSchema; setEnvironmentForm((current) => ( current.driver !== "sandbox" || (current.sandboxProvider.trim().length > 0 && current.sandboxProvider !== "fake") ? current : { ...current, sandboxProvider: firstProvider, + sandboxConfig: firstSchema ? getDefaultValues(firstSchema as any) : {}, } )); }, [discoveredPluginSandboxProviders, environmentForm.driver, environmentForm.sandboxProvider]); @@ -593,10 +586,7 @@ export function CompanySettings() { (environmentForm.driver !== "sandbox" || environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake" && - environmentForm.sandboxImage.trim().length > 0 && - environmentForm.sandboxTimeoutMs.trim().length > 0 && - Number.isFinite(Number(environmentForm.sandboxTimeoutMs)) && - Number(environmentForm.sandboxTimeoutMs) > 0); + Object.keys(sandboxConfigErrors).length === 0); return (
@@ -835,10 +825,14 @@ export function CompanySettings() {
) : environment.driver === "sandbox" ? (
- {String(environment.config.provider ?? "fake")} sandbox provider ·{" "} - {typeof environment.config.image === "string" - ? environment.config.image - : "ubuntu:24.04"} + {(() => { + const provider = + typeof environment.config.provider === "string" ? environment.config.provider : "sandbox"; + const displayName = + environmentCapabilities?.sandboxProviders?.[provider]?.displayName ?? provider; + const summary = summarizeSandboxConfig(environment.config as Record); + return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`; + })()}
) : (
Runs on this Paperclip host.
@@ -920,6 +914,16 @@ export function CompanySettings() { e.target.value === "sandbox" ? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || "" : current.sandboxProvider, + sandboxConfig: + e.target.value === "sandbox" + ? ( + current.sandboxProvider.trim().length > 0 && current.driver === "sandbox" + ? current.sandboxConfig + : discoveredPluginSandboxProviders[0]?.configSchema + ? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any) + : {} + ) + : current.sandboxConfig, driver: e.target.value === "local" ? "local" @@ -1024,11 +1028,20 @@ export function CompanySettings() { - - setEnvironmentForm((current) => ({ ...current, sandboxImage: e.target.value }))} - /> - - - - setEnvironmentForm((current) => ({ ...current, sandboxTimeoutMs: e.target.value }))} - /> - -
- - setEnvironmentForm((current) => ({ ...current, sandboxReuseLease: checked }))} - /> +
+ {selectedSandboxProvider?.description ? ( +
+ {selectedSandboxProvider.description} +
+ ) : null} + {selectedSandboxSchema ? ( + + setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))} + errors={sandboxConfigErrors} + /> + ) : ( +
+ This provider does not declare additional configuration fields. +
+ )}
) : null}