diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index 98753b41d1..788c6351aa 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -202,6 +202,110 @@ describe("buildPluginWorkerEnv", () => { PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", }); }); + + it("passes a first-party sandbox provider's documented credential env var to its own worker", () => { + const env = buildPluginWorkerEnv({ + manifest: { + capabilities: ["environment.drivers.register"], + environmentDrivers: [{ driverKey: "daytona" }], + }, + packageName: "@paperclipai/plugin-daytona", + packagePath: null, + instanceInfo, + processEnv: { + DAYTONA_API_KEY: "daytona-token", + NOVITA_API_KEY: "novita-token", + E2B_API_KEY: " ", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + DAYTONA_API_KEY: "daytona-token", + }); + }); + + it("passes the credential to a first-party plugin installed from the bundled catalog", () => { + const env = buildPluginWorkerEnv({ + manifest: { + capabilities: ["environment.drivers.register"], + environmentDrivers: [{ driverKey: "daytona" }], + }, + packageName: "@paperclipai/plugin-daytona", + packagePath: "/app/packages/plugins/sandbox-providers/daytona", + trustedLocalPluginRoots: ["/app/packages/plugins"], + instanceInfo, + processEnv: { + DAYTONA_API_KEY: "daytona-token", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + DAYTONA_API_KEY: "daytona-token", + }); + }); + + it("does not pass the credential to a local plugin that self-declares the first-party name", () => { + const env = buildPluginWorkerEnv({ + manifest: { + capabilities: ["environment.drivers.register"], + environmentDrivers: [{ driverKey: "daytona" }], + }, + packageName: "@paperclipai/plugin-daytona", + packagePath: "/home/operator/.paperclip/plugins/fake-daytona", + trustedLocalPluginRoots: ["/app/packages/plugins"], + instanceInfo, + processEnv: { + DAYTONA_API_KEY: "daytona-token", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + }); + }); + + it("does not pass a credential to a third-party plugin that claims a first-party driver key", () => { + const env = buildPluginWorkerEnv({ + manifest: { + capabilities: ["environment.drivers.register"], + environmentDrivers: [{ driverKey: "daytona" }], + }, + packageName: "@acme/plugin-fake-daytona", + instanceInfo, + processEnv: { + DAYTONA_API_KEY: "daytona-token", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + }); + }); + + it("does not pass a credential when the first-party package omits its expected driver key", () => { + const env = buildPluginWorkerEnv({ + manifest: { + capabilities: ["environment.drivers.register"], + environmentDrivers: [{ driverKey: "kubernetes" }], + }, + packageName: "@paperclipai/plugin-daytona", + instanceInfo, + processEnv: { + DAYTONA_API_KEY: "daytona-token", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + }); + }); }); describeEmbeddedPostgres("plugin database namespaces", () => { diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index b2079a93e4..7e24046e38 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -50,6 +50,7 @@ import type { PluginJobStore } from "./plugin-job-store.js"; import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js"; import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; import { pluginDatabaseService } from "./plugin-database.js"; +import { resolveBundledCatalogRoot } from "./bundled-plugins.js"; const execFileAsync = promisify(execFile); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -118,8 +119,43 @@ const K8S_IN_CLUSTER_ENV_PASSTHROUGH = [ "KUBERNETES_SERVICE_PORT_HTTPS", ]; +/** + * Each first-party sandbox provider's documented credential fallback env + * var. Environment rows may omit `config.apiKey` (managed/platform- + * provisioned rows always do — see `managed-environments.ts`), in which + * case the provider reads its documented process env var. That fallback + * executes inside the plugin worker, whose environment is scrubbed, so + * the deployment-level var must be forwarded explicitly. + * + * Keyed by the installed npm package name and cross-checked against the + * manifest's declared driver key — but name and manifest are both + * plugin-authored, so neither is proof of identity on its own. The gate + * therefore also requires a trusted install origin: a registry install + * (`packagePath` null — the `@paperclipai` scope is project-controlled at + * the registry), or a local path inside the repo/bundled plugin catalog, + * which ships inside the release image and is as trusted as the server + * code itself. An operator-added local plugin directory can claim any + * name and driver key and still receives nothing. + */ +const SANDBOX_PROVIDER_CREDENTIAL_ENV_PASSTHROUGH: Record< + string, + { driverKey: string; envVars: readonly string[] } +> = { + "@paperclipai/plugin-daytona": { driverKey: "daytona", envVars: ["DAYTONA_API_KEY"] }, + "@paperclipai/plugin-e2b": { driverKey: "e2b", envVars: ["E2B_API_KEY"] }, + "@paperclipai/plugin-exe-dev": { driverKey: "exe-dev", envVars: ["EXE_API_KEY"] }, + "@paperclipai/plugin-novita-sandbox": { driverKey: "novita", envVars: ["NOVITA_API_KEY"] }, +}; + export function buildPluginWorkerEnv(input: { - manifest: Pick; + manifest: Pick & { + environmentDrivers?: ReadonlyArray<{ driverKey: string }>; + }; + packageName?: string; + /** Local install path (`PluginRecord.packagePath`); null for registry installs. */ + packagePath?: string | null; + /** Test seam; defaults to the repo plugin tree and the bundled catalog root. */ + trustedLocalPluginRoots?: readonly string[]; instanceInfo: { deploymentMode?: string | null; deploymentExposure?: string | null }; processEnv?: NodeJS.ProcessEnv; }): Record { @@ -132,7 +168,22 @@ export function buildPluginWorkerEnv(input: { && input.manifest.capabilities.includes("environment.drivers.register"); if (!canRegisterEnvironmentDrivers) return env; - for (const key of [...ADAPTER_ENV_PASSTHROUGH, ...K8S_IN_CLUSTER_ENV_PASSTHROUGH]) { + const trustedLocalRoots = input.trustedLocalPluginRoots + ?? [BUNDLED_LOCAL_PLUGIN_ROOT, resolveBundledCatalogRoot(processEnv)]; + const installOriginTrusted = + input.packagePath == null + || trustedLocalRoots.some((root) => isPathWithin(root, path.resolve(input.packagePath as string))); + const credentialEntry = installOriginTrusted && input.packageName + ? SANDBOX_PROVIDER_CREDENTIAL_ENV_PASSTHROUGH[input.packageName] + : undefined; + const credentialKeys = + credentialEntry + && (input.manifest.environmentDrivers ?? []).some( + (driver) => driver.driverKey === credentialEntry.driverKey, + ) + ? credentialEntry.envVars + : []; + for (const key of [...ADAPTER_ENV_PASSTHROUGH, ...K8S_IN_CLUSTER_ENV_PASSTHROUGH, ...credentialKeys]) { const value = processEnv[key]; if (value && value.trim().length > 0) { env[key] = value; @@ -2222,7 +2273,12 @@ export function pluginLoader( databaseNamespace, hostHandlers, autoRestart: true, - env: buildPluginWorkerEnv({ manifest, instanceInfo }), + env: buildPluginWorkerEnv({ + manifest, + packageName: activePlugin.packageName, + packagePath: activePlugin.packagePath, + instanceInfo, + }), // Authorize the worker to act on each configured company from its // proactive loops/timers (LOOA-629). Seeded here so it is in place // before any setup()-time worker→host call (LOOA-695). The authorized diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 44b79ccfeb..8b24f2eea7 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -490,9 +490,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { currentDefaultEnvironmentId.length > 0 || runnableEnvironments.length >= 1 ); + const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true; const inheritedEnvironmentLabel = instanceDefaultEnvironment ? `${instanceDefaultEnvironment.name} (${instanceDefaultEnvironment.driver})` - : "Local"; + : managedSandboxOnly + ? "Managed sandbox" + : "Local"; // Fetch adapter models for the effective adapter type const modelQueryKey = selectedCompanyId diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 3d0d8e3294..b0d5c10ff9 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -1387,4 +1387,36 @@ describe("CompanyEnvironments — test provider button", () => { expect(mockEnvironmentsApi.disableCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); }); }); + + it("offers the implicit Local option in the default picker by default", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + + const options = Array.from(container.querySelectorAll("option")); + expect(options.some((option) => option.textContent?.trim() === "Local")).toBe(true); + }); + + it("hides the implicit Local option in the default picker under managed-sandbox-only", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableEnvironments: true, + enableManagedSandboxOnly: true, + }); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + + const options = Array.from(container.querySelectorAll("option")); + expect(options.some((option) => option.textContent?.trim() === "Local")).toBe(false); + // Saved non-local environments remain selectable defaults. + expect(options.some((option) => option.textContent?.includes("Alpha"))).toBe(true); + }); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 59c2a5c692..2c337bf188 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1190,6 +1190,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) retry: false, }); const environmentsEnabled = experimentalSettings?.enableEnvironments === true; + const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true; const { data: environments } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], @@ -1660,7 +1661,18 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) defaultEnvironmentMutation.mutate(event.target.value || null)} disabled={defaultEnvironmentMutation.isPending} > - + {managedSandboxOnly ? ( + // Managed-sandbox-only instances never execute locally, so + // the implicit local fallback is not a legal default. The + // placeholder only renders while no default is stamped yet. + instanceDefaultEnvironmentId === "" ? ( + + ) : null + ) : ( + + )} {nonLocalEnvironments.map((environment) => (