From 6d04feb54e5c2357b7c6215c78089cb50540b40d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 23:04:01 -0600 Subject: [PATCH] fix(plugins): accept secret_ref bindings in string secret-ref config fields Plugin manifests declare credential fields as `type: "string", format: "secret-ref"`, while the settings form, the plugin secrets handler and the SDK all use the `{ type: "secret_ref", secretId, version }` binding object (and reject a bare secret id). Saving the binding failed schema validation with "must be string", so no value could be saved for such a field. Validate the binding's secret id in its place; stored config is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/plugin-config-validator.test.ts | 50 +++++++++++++++++++ .../src/services/plugin-config-validator.ts | 23 ++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/plugin-config-validator.test.ts diff --git a/server/src/__tests__/plugin-config-validator.test.ts b/server/src/__tests__/plugin-config-validator.test.ts new file mode 100644 index 00000000000..70648a21279 --- /dev/null +++ b/server/src/__tests__/plugin-config-validator.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { JsonSchema } from "@paperclipai/shared"; +import { validateInstanceConfig } from "../services/plugin-config-validator.js"; + +const SECRET_ID = "11111111-1111-4111-8111-111111111111"; +const binding = { type: "secret_ref", secretId: SECRET_ID, version: "latest" }; + +// The shape plugin manifests use: a string field marked as a secret reference. +const schema = { + type: "object", + properties: { + apiKey: { type: "string", title: "API key", format: "secret-ref" }, + region: { type: "string" }, + auth: { + type: "object", + properties: { token: { type: "string", format: "secret-ref" } }, + additionalProperties: false, + }, + }, + required: ["apiKey"], + additionalProperties: false, +} as unknown as JsonSchema; + +describe("validateInstanceConfig secret-ref fields", () => { + it("accepts the secret_ref binding object the settings form saves for a string secret-ref field", () => { + const configJson = { apiKey: binding, region: "us", auth: { token: { ...binding, version: 2 } } }; + expect(validateInstanceConfig(configJson, schema)).toEqual({ valid: true }); + // Validation does not rewrite what gets stored or sent to the worker. + expect(configJson.apiKey).toEqual(binding); + }); + + it("still enforces the rest of the schema around a binding", () => { + expect(validateInstanceConfig({ apiKey: binding, region: 5 }, schema)).toMatchObject({ + valid: false, + errors: [{ field: "/region" }], + }); + expect(validateInstanceConfig({ region: "us" }, schema).valid).toBe(false); + }); + + it("rejects objects that are not valid secret_ref bindings and objects in ordinary string fields", () => { + expect(validateInstanceConfig({ apiKey: { type: "secret_ref", secretId: "not-a-uuid" } }, schema)).toMatchObject({ + valid: false, + errors: [{ field: "/apiKey", message: "must be string" }], + }); + expect(validateInstanceConfig({ apiKey: binding, region: binding }, schema)).toMatchObject({ + valid: false, + errors: [{ field: "/region", message: "must be string" }], + }); + }); +}); diff --git a/server/src/services/plugin-config-validator.ts b/server/src/services/plugin-config-validator.ts index 6eb95aa0fb2..285868edf7d 100644 --- a/server/src/services/plugin-config-validator.ts +++ b/server/src/services/plugin-config-validator.ts @@ -11,12 +11,33 @@ import Ajv, { type ErrorObject } from "ajv"; import addFormats from "ajv-formats"; import type { JsonSchema } from "@paperclipai/shared"; +import { + collectSecretRefPaths, + parseSecretRefBindingObject, + readConfigValueAtPath, + writeConfigValueAtPath, +} from "./json-schema-secret-refs.js"; export interface ConfigValidationResult { valid: boolean; errors?: { field: string; message: string }[]; } +/** + * Secret-ref fields are declared `type: "string"`, but the settings form and the + * plugin secrets API store the `{ type: "secret_ref", secretId, version }` binding + * object there. Validate the binding's secret id in its place so the schema + * checks the field without rejecting the only shape the secrets handler accepts. + */ +function secretRefsAsIds(configJson: Record, schema: JsonSchema) { + let candidate = configJson; + for (const path of collectSecretRefPaths(schema as Record)) { + const binding = parseSecretRefBindingObject(readConfigValueAtPath(candidate, path)); + if (binding) candidate = writeConfigValueAtPath(candidate, path, binding.secretId); + } + return candidate; +} + /** * Validate a config object against a JSON Schema. * @@ -39,7 +60,7 @@ export function validateInstanceConfig( // hint only — UUID validation happens in the secrets handler at resolve time. ajv.addFormat("secret-ref", { validate: () => true }); const validate = ajv.compile(schema); - const valid = validate(configJson); + const valid = validate(secretRefsAsIds(configJson, schema)); if (valid) { return { valid: true };