Files
PaperClipAI/server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts
T
Devin Foley 9f5af4ea5d fix(server): accept secret_ref binding objects in sandbox provider environment config (#10355)
## 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
2026-07-28 10:50:13 -07:00

121 lines
3.4 KiB
TypeScript

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<string, unknown> }) => ({
ok: true,
normalizedConfig: { ...params.config },
})),
} as unknown as PluginWorkerManager & { call: ReturnType<typeof vi.fn> };
}
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",
},
});
});
});