From 9f5af4ea5d33ee0b40b972eb21b6e8e42cd0cf25 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 28 Jul 2026 10:50:13 -0700 Subject: [PATCH] fix(server): accept secret_ref binding objects in sandbox provider environment config (#10355) 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 > - Agents execute in environments; sandbox provider plugins (Daytona, Modal, e2b, …) declare their config via a JSON schema, with credentials marked `format: "secret-ref"` > - The environments UI renders those fields with a secret picker that submits `{ type: "secret_ref", secretId, version }` binding objects, while the server-side environment config paths only understood raw string values and bare secret-id strings > - The binding object reached the plugin worker's `environmentValidateConfig` untouched; plugins parse non-string config values as absent, so saving or testing an environment with a picker-bound secret always failed validation (e.g. "Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.", "Modal sandbox environments require tokenId and tokenSecret.") > - Worse, an environment first saved with raw pasted values becomes uneditable: the stored value is a secret reference, the edit form re-submits it as a binding object, and every subsequent save fails the same way > - This pull request canonicalizes binding objects to the bare secret id before plugin validation, and teaches the persistence/runtime/probe secret-ref resolvers to accept the object shape defensively > - The benefit is that picker-bound secrets work for every schema-driven sandbox provider — create, edit, and Test — with no plugin changes required ## Linked Issues or Issue Description Fixes #10105 The same failure reproduces with the Daytona provider: Settings → Instance settings → Environments → New, driver sandbox, provider daytona, bind Api Key to an existing secret via the picker → Save fails with "Daytona sandbox environments require an API key in config or DAYTONA_API_KEY." ## What Changed - `server/src/services/json-schema-secret-refs.ts`: new `parseSecretRefBindingObject()` that recognizes the `{ type: "secret_ref", secretId, version? }` shape the secret picker submits (version defaults to `"latest"`; malformed objects return null). - `server/src/services/plugin-environment-driver.ts`: `validatePluginSandboxProviderConfig()` now canonicalizes binding objects at the driver schema's `format: "secret-ref"` paths to the bare secret id (the persisted shape) before invoking the plugin worker's `environmentValidateConfig`. Pinned numeric versions are rejected with a clear 422, since sandbox provider references always resolve the latest version — silently resolving a different version would be worse. - `server/src/services/environment-config.ts`: the persistence, runtime, and probe secret-ref resolvers plus `collectEnvironmentSecretRefs()` accept the binding-object shape defensively, so any previously persisted object-shaped refs (from providers whose validation tolerated them) resolve instead of being silently skipped; the missing-companyId runtime guard also now fails closed for object-shaped refs. ## Verification - `npx vitest run server/src/__tests__/json-schema-secret-refs.test.ts server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts server/src/__tests__/environment-routes.test.ts server/src/__tests__/environment-config.test.ts` — 82 tests pass, including new coverage: binding-object canonicalization before plugin validation, pinned-version rejection, raw-string pass-through, and a route-level create with a picker-submitted binding object persisting the bare secret id without minting a duplicate secret. - `npx vitest run server/src/__tests__/environment-runtime.test.ts` — 24 tests pass against embedded Postgres, including a new test that persists an object-shaped ref and verifies runtime resolution produces the plaintext credential for the plugin worker. - `pnpm typecheck` in `server/` — clean. ## Risks - Low. The canonical persisted shape (bare secret-id string) is unchanged, so existing saved environments and lease-resume fingerprints are unaffected; raw pasted values and bare-id strings take exactly the same code path as before. - New behavior only triggers where a save/probe previously failed 422 (binding objects at secret-ref paths) or where an object-shaped ref was previously skipped silently at runtime (now resolved, or failed closed without a companyId). - Pinned binding versions at sandbox-provider paths are now an explicit 422 instead of an accidental validation failure; no UI submits pinned versions today (`allowVersionSelector={false}`). ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking and agentic tool use (Claude Code harness): source diagnosis, fix, and tests. ## 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 (e.g. `docs/...`, `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 (no doc surface changed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/__tests__/environment-routes.test.ts | 73 +++++++++++ .../src/__tests__/environment-runtime.test.ts | 83 ++++++++++++ .../__tests__/json-schema-secret-refs.test.ts | 35 ++++- ...sandbox-provider-config-validation.test.ts | 120 ++++++++++++++++++ server/src/services/environment-config.ts | 33 ++++- .../src/services/json-schema-secret-refs.ts | 25 ++++ .../src/services/plugin-environment-driver.ts | 30 ++++- 7 files changed, 392 insertions(+), 7 deletions(-) create mode 100644 server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index c6c33353b3..94cd7d4894 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1598,6 +1598,79 @@ describe("environment routes", () => { ); }); + it("persists a picker-submitted secret_ref binding object as the bare secret id", async () => { + const secretId = "11111111-1111-1111-1111-111111111111"; + const environment = { + ...createEnvironment(), + id: "env-sandbox-secure-plugin", + name: "Secure Sandbox", + driver: "sandbox" as const, + config: { + provider: "secure-plugin", + template: "base", + apiKey: secretId, + timeoutMs: 450000, + reuseLease: true, + }, + }; + mockEnvironmentService.create.mockResolvedValue(environment); + mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ config }: { config: Record }) => ({ + normalizedConfig: { ...config }, + 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: { type: "secret_ref", secretId, version: "latest" }, + timeoutMs: 450000, + reuseLease: true, + }, + }); + + expect(res.status).toBe(201); + expect(mockEnvironmentService.create).toHaveBeenCalledWith({ + name: "Secure Sandbox", + driver: "sandbox", + status: "active", + config: { + provider: "secure-plugin", + template: "base", + apiKey: secretId, + timeoutMs: 450000, + reuseLease: true, + }, + envVars: {}, + }); + expect(mockSecretService.create).not.toHaveBeenCalled(); + }); + it("uses the configured provider for schema-driven sandbox secret fields", async () => { process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager"; const environment = { diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 0618dd087e..c90e5e3cc9 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -27,6 +27,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { resolveEnvironmentDriverConfigForRuntime } from "../services/environment-config.ts"; import { environmentRuntimeService, findReusableSandboxLeaseId } from "../services/environment-runtime.ts"; import { environmentService } from "../services/environments.ts"; import { secretService } from "../services/secrets.ts"; @@ -849,6 +850,88 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }), 31234); }); + it("resolves persisted secret_ref binding objects in sandbox provider config at runtime", async () => { + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment } = 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: { type: "secret_ref", secretId: apiSecret.id, version: "latest" }, + timeoutMs: 1234, + reuseLease: false, + }; + const environment = { + ...baseEnvironment, + name: "Secure Plugin Sandbox", + driver: "sandbox", + config: providerConfig, + }; + await secretService(db).createBinding({ + companyId, + secretId: apiSecret.id, + targetType: "environment", + targetId: environment.id, + configPath: "apiKey", + }); + 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 resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); + + expect(resolved.driver).toBe("sandbox"); + expect(resolved.config).toMatchObject({ + provider: "secure-plugin", + template: "base", + apiKey: "resolved-provider-key", + }); + }); + it("waits briefly for a ready sandbox provider plugin worker to come online", async () => { const pluginId = randomUUID(); const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); diff --git a/server/src/__tests__/json-schema-secret-refs.test.ts b/server/src/__tests__/json-schema-secret-refs.test.ts index f9394c5c3a..7224b10b8c 100644 --- a/server/src/__tests__/json-schema-secret-refs.test.ts +++ b/server/src/__tests__/json-schema-secret-refs.test.ts @@ -1,5 +1,38 @@ import { describe, expect, it } from "vitest"; -import { collectSecretRefPaths } from "../services/json-schema-secret-refs.ts"; +import { collectSecretRefPaths, parseSecretRefBindingObject } from "../services/json-schema-secret-refs.ts"; + +describe("parseSecretRefBindingObject", () => { + const secretId = "11111111-1111-1111-1111-111111111111"; + + it("parses a binding object and defaults the version to latest", () => { + expect(parseSecretRefBindingObject({ type: "secret_ref", secretId })).toEqual({ + secretId, + version: "latest", + }); + expect(parseSecretRefBindingObject({ type: "secret_ref", secretId, version: "latest" })).toEqual({ + secretId, + version: "latest", + }); + }); + + it("parses a pinned numeric version", () => { + expect(parseSecretRefBindingObject({ type: "secret_ref", secretId, version: 3 })).toEqual({ + secretId, + version: 3, + }); + }); + + it("rejects non-binding values", () => { + expect(parseSecretRefBindingObject(secretId)).toBeNull(); + expect(parseSecretRefBindingObject("raw-api-key")).toBeNull(); + expect(parseSecretRefBindingObject(null)).toBeNull(); + expect(parseSecretRefBindingObject([{ type: "secret_ref", secretId }])).toBeNull(); + expect(parseSecretRefBindingObject({ type: "user_secret_ref", secretId })).toBeNull(); + expect(parseSecretRefBindingObject({ type: "secret_ref", secretId: "not-a-uuid" })).toBeNull(); + expect(parseSecretRefBindingObject({ type: "secret_ref", secretId, version: 0 })).toBeNull(); + expect(parseSecretRefBindingObject({ type: "secret_ref", secretId, version: "2" })).toBeNull(); + }); +}); describe("collectSecretRefPaths", () => { it("collects nested secret-ref paths from object properties", () => { diff --git a/server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts b/server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts new file mode 100644 index 0000000000..fbd594948b --- /dev/null +++ b/server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { validatePluginSandboxProviderConfig } from "../services/plugin-environment-driver.ts"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts"; +import type { Db } from "@paperclipai/db"; + +const mockList = vi.fn(); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => ({ + list: mockList, + }), +})); + +const PLUGIN_ID = "22222222-2222-2222-2222-222222222222"; +const SECRET_ID = "11111111-1111-1111-1111-111111111111"; + +function seedProviderPlugin() { + mockList.mockResolvedValue([ + { + id: PLUGIN_ID, + pluginKey: "acme.secure-sandbox-provider", + status: "ready", + manifestJson: { + 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" }, + }, + }, + }, + ], + }, + }, + ]); +} + +function createWorkerManager() { + return { + isRunning: vi.fn(() => true), + call: vi.fn(async (_pluginId: string, _method: string, params: { config: Record }) => ({ + ok: true, + normalizedConfig: { ...params.config }, + })), + } as unknown as PluginWorkerManager & { call: ReturnType }; +} + +describe("validatePluginSandboxProviderConfig secret-ref bindings", () => { + beforeEach(() => { + vi.clearAllMocks(); + seedProviderPlugin(); + }); + + it("canonicalizes secret_ref binding objects to bare secret ids before the plugin validates", async () => { + const workerManager = createWorkerManager(); + + const result = await validatePluginSandboxProviderConfig({ + db: {} as Db, + workerManager, + provider: "secure-plugin", + config: { + template: "base", + apiKey: { type: "secret_ref", secretId: SECRET_ID, version: "latest" }, + timeoutMs: 1234, + }, + }); + + expect(workerManager.call).toHaveBeenCalledWith(PLUGIN_ID, "environmentValidateConfig", { + driverKey: "secure-plugin", + config: { + template: "base", + apiKey: SECRET_ID, + timeoutMs: 1234, + }, + }); + expect(result.normalizedConfig.apiKey).toBe(SECRET_ID); + }); + + it("rejects pinned secret binding versions before calling the plugin", async () => { + const workerManager = createWorkerManager(); + + await expect(validatePluginSandboxProviderConfig({ + db: {} as Db, + workerManager, + provider: "secure-plugin", + config: { + apiKey: { type: "secret_ref", secretId: SECRET_ID, version: 3 }, + }, + })).rejects.toThrow(/pins version 3/); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("passes raw strings and bare secret ids through untouched", async () => { + const workerManager = createWorkerManager(); + + await validatePluginSandboxProviderConfig({ + db: {} as Db, + workerManager, + provider: "secure-plugin", + config: { + template: "base", + apiKey: "raw-provider-key", + }, + }); + + expect(workerManager.call).toHaveBeenCalledWith(PLUGIN_ID, "environmentValidateConfig", { + driverKey: "secure-plugin", + config: { + template: "base", + apiKey: "raw-provider-key", + }, + }); + }); +}); diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 054224deb5..efd33c818e 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -25,6 +25,7 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { collectSecretRefPaths, isUuidSecretRef, + parseSecretRefBindingObject, readConfigValueAtPath, writeConfigValueAtPath, } from "./json-schema-secret-refs.js"; @@ -200,6 +201,25 @@ async function createEnvironmentSecret(input: { }; } +/** + * Secret pickers submit `{ type: "secret_ref", secretId, version }` binding + * objects for `format: "secret-ref"` fields, while persisted configs store the + * bare secret id. Collapse binding objects to the secret id so every consumer + * downstream deals with one shape. Sandbox provider references always resolve + * the latest version, so pinned bindings are rejected rather than silently + * resolved to a different version than the caller asked for. + */ +function canonicalizeSecretRefValue(value: unknown, path: string): unknown { + const binding = parseSecretRefBindingObject(value); + if (!binding) return value; + if (binding.version !== "latest") { + throw unprocessable( + `Secret binding at ${path} pins version ${binding.version}; sandbox provider secret references always resolve the latest version.`, + ); + } + return binding.secretId; +} + async function persistConfigSecretRefs(input: { db: Db; companyId: string; @@ -212,7 +232,7 @@ async function persistConfigSecretRefs(input: { }): Promise> { let nextConfig = { ...input.config }; for (const path of collectSecretRefPaths(input.schema)) { - const rawValue = readConfigValueAtPath(nextConfig, path); + const rawValue = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), path); if (typeof rawValue !== "string") continue; const trimmed = rawValue.trim(); if (trimmed.length === 0) { @@ -252,7 +272,7 @@ async function resolveConfigSecretRefsForRuntime(input: { const secrets = secretService(input.db); let nextConfig = { ...input.config }; for (const path of collectSecretRefPaths(input.schema)) { - const current = readConfigValueAtPath(nextConfig, path); + const current = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), path); if (typeof current !== "string") continue; const trimmed = current.trim(); if (!isUuidSecretRef(trimmed)) continue; @@ -291,7 +311,7 @@ async function resolveConfigSecretRefsForProbe(input: { const secrets = secretService(input.db); let nextConfig = { ...input.config }; for (const path of collectSecretRefPaths(input.schema)) { - const current = readConfigValueAtPath(nextConfig, path); + const current = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), path); if (typeof current !== "string") continue; const trimmed = current.trim(); if (!isUuidSecretRef(trimmed)) continue; @@ -332,6 +352,11 @@ export async function collectEnvironmentSecretRefs(input: { const refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }> = []; for (const path of collectSecretRefPaths(schema)) { const current = readConfigValueAtPath(parsed.config as Record, path); + const binding = parseSecretRefBindingObject(current); + if (binding) { + refs.push({ secretId: binding.secretId, configPath: path, versionSelector: binding.version }); + continue; + } if (typeof current === "string" && isUuidSecretRef(current.trim())) { refs.push({ secretId: current.trim(), configPath: path, versionSelector: "latest" }); } @@ -623,7 +648,7 @@ export async function resolveEnvironmentDriverConfigForRuntime( } else { for (const path of collectSecretRefPaths(schema)) { const current = readConfigValueAtPath(parsed.config as Record, path); - if (typeof current === "string" && isUuidSecretRef(current.trim())) { + if (parseSecretRefBindingObject(current) || (typeof current === "string" && isUuidSecretRef(current.trim()))) { throw unprocessable("Runtime secret resolution requires a companyId context"); } } diff --git a/server/src/services/json-schema-secret-refs.ts b/server/src/services/json-schema-secret-refs.ts index 91b3bcc212..b01e38a62c 100644 --- a/server/src/services/json-schema-secret-refs.ts +++ b/server/src/services/json-schema-secret-refs.ts @@ -5,6 +5,31 @@ export function isUuidSecretRef(value: string): boolean { return UUID_RE.test(value); } +export type SecretRefBindingObject = { + secretId: string; + version: "latest" | number; +}; + +/** + * Parses the `{ type: "secret_ref", secretId, version? }` binding object that + * secret pickers submit for `format: "secret-ref"` config fields. Returns null + * for anything else (raw values, bare secret-id strings, malformed objects). + */ +export function parseSecretRefBindingObject(value: unknown): SecretRefBindingObject | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.type !== "secret_ref") return null; + if (typeof record.secretId !== "string" || !isUuidSecretRef(record.secretId.trim())) return null; + const version = record.version; + if (version === undefined || version === null || version === "latest") { + return { secretId: record.secretId.trim(), version: "latest" }; + } + if (typeof version === "number" && Number.isInteger(version) && version > 0) { + return { secretId: record.secretId.trim(), version }; + } + return null; +} + export function collectSecretRefPaths( schema: Record | null | undefined, ): Set { diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index edc80ed962..72d2425f19 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -21,6 +21,12 @@ import type { PluginEnvironmentRealizeWorkspaceResult, } from "@paperclipai/plugin-sdk"; import { unprocessable } from "../errors.js"; +import { + collectSecretRefPaths, + parseSecretRefBindingObject, + readConfigValueAtPath, + writeConfigValueAtPath, +} from "./json-schema-secret-refs.js"; import { pluginRegistryService } from "./plugin-registry.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -135,9 +141,29 @@ export async function validatePluginSandboxProviderConfig(input: { throw unprocessable(`Sandbox provider "${input.provider}" is not installed or its plugin worker is not running.`); } + // Secret pickers submit `{ type: "secret_ref", secretId, version }` binding + // objects for `format: "secret-ref"` fields. Plugins only understand string + // config values, so canonicalize bindings to the bare secret id (the + // persisted shape) before the plugin validates. + const configSchema = + resolved.driver.configSchema && typeof resolved.driver.configSchema === "object" && !Array.isArray(resolved.driver.configSchema) + ? resolved.driver.configSchema as Record + : null; + let config = input.config; + for (const path of collectSecretRefPaths(configSchema)) { + const binding = parseSecretRefBindingObject(readConfigValueAtPath(config, path)); + if (!binding) continue; + if (binding.version !== "latest") { + throw unprocessable( + `Secret binding at ${path} pins version ${binding.version}; sandbox provider secret references always resolve the latest version.`, + ); + } + config = writeConfigValueAtPath(config, path, binding.secretId); + } + const result = await input.workerManager.call(resolved.plugin.id, "environmentValidateConfig", { driverKey: input.provider, - config: input.config, + config, }); if (!result.ok) { @@ -151,7 +177,7 @@ export async function validatePluginSandboxProviderConfig(input: { } return { - normalizedConfig: result.normalizedConfig ?? input.config, + normalizedConfig: result.normalizedConfig ?? config, pluginId: resolved.plugin.id, pluginKey: resolved.plugin.pluginKey, driver: resolved.driver,