[codex] Provider vault secrets UX (#6381)

## Thinking Path

> - Paperclip orchestrates AI agents that need scoped, auditable access
to secrets
> - Hosted and external deployments need provider vault configuration
without exposing secret values in Paperclip metadata
> - AWS Secrets Manager vault setup previously required too much manual
operator knowledge
> - Provider vault discovery and removal belong together as an
independent secrets-management improvement
> - This pull request adds AWS provider vault discovery/prefill plus
vault removal flows
> - The benefit is a safer operator path for configuring external secret
storage before higher-level cloud workflows depend on it

## What Changed

- Added shared validators/types for AWS provider vault discovery
payloads and safe provider metadata.
- Implemented AWS provider vault discovery preview on the server.
- Added provider vault removal service/route behavior.
- Added Secrets page UI for discovery prefill, removal messaging, and
related rendering coverage.
- Added Storybook provider-vault fixtures and captured screenshots for
the new UX states.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec vitest run packages/shared/src/validators/secret.test.ts
server/src/__tests__/aws-secrets-manager-provider.test.ts
server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts
ui/src/pages/Secrets.render.test.tsx`
- Result: 4 files passed, 1 embedded Postgres-backed file skipped on
this host because local Postgres init was unavailable.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Secrets.render.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- Storybook screenshot capture against `Product/Secrets` on
`http://127.0.0.1:60381/iframe.html?id=product-secrets--secrets-inventory&viewMode=story&globals=theme:dark`

## Screenshots

Provider vaults tab after this change:

![Provider vaults
tab](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/provider-vaults-tab.png)

AWS discovery candidate flow:

![AWS discovery candidate
flow](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/aws-discovery-candidates.png)

Provider vault removal confirmation:

![Provider vault removal
confirmation](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png)

## Risks

- Secret provider metadata handling must remain non-sensitive;
validators reject credential-bearing Vault URLs and sensitive AWS
discovery keys.
- AWS discovery depends on deployment credentials being configured
correctly outside Paperclip-managed company secrets.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## 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
- [x] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-19 15:50:23 -05:00 committed by GitHub
parent 9c29394f4d
commit d67347be77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1602 additions and 13 deletions

View file

@ -4,18 +4,25 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
import type {
CompanySecretProviderConfig,
SecretProviderConfigDiscoveryPreviewResult,
SecretProviderDescriptor,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProviderVaultsTab, Secrets } from "./Secrets";
import { ApiError } from "../api/client";
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
providers: vi.fn(),
providerHealth: vi.fn(),
providerConfigs: vi.fn(),
providerConfigDiscoveryPreview: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
removeProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
create: vi.fn(),
@ -133,6 +140,79 @@ async function flushReact() {
});
}
function makeDiscoveryPreview(
overrides: Partial<SecretProviderConfigDiscoveryPreviewResult> = {},
): SecretProviderConfigDiscoveryPreviewResult {
return {
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 2,
skippedForeignPaperclipSampleCount: 0,
warnings: [],
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: "alias/paperclip-secrets",
ownerTag: "platform",
environmentTag: "production",
},
sampleCount: 2,
samples: [
{
name: "paperclip/prod-use1/company-1/openai",
hasKmsKey: true,
tagKeys: ["owner", "environment"],
},
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: "alias/paperclip-secrets",
hasKmsKey: true,
sampleCount: 2,
paperclipManagedSampleCount: 0,
skippedForeignPaperclipSampleCount: 0,
},
warnings: [],
},
],
...overrides,
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function openAwsVaultDialog() {
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
) as HTMLButtonElement | undefined;
await act(async () => {
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
vaultTabButton?.click();
});
await flushReact();
const addVaultButtons = [...document.querySelectorAll("button")].filter(
(button) => button.textContent?.includes("Add vault"),
) as HTMLButtonElement[];
await act(async () => {
addVaultButtons[1]?.click();
});
await flushReact();
}
describe("Secrets page layout", () => {
let container: HTMLDivElement;
@ -153,6 +233,7 @@ describe("Secrets page layout", () => {
],
});
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
});
afterEach(() => {
@ -200,6 +281,7 @@ describe("Secrets page layout", () => {
onCreate={vi.fn()}
onEdit={vi.fn()}
onDisable={vi.fn()}
onRemove={vi.fn()}
onSetDefault={vi.fn()}
onHealthCheck={vi.fn()}
pendingActionId={null}
@ -218,6 +300,64 @@ describe("Secrets page layout", () => {
});
});
it("warns that removing a provider vault only removes Paperclip config", async () => {
mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
) as HTMLButtonElement | undefined;
await act(async () => {
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
vaultTabButton?.click();
});
await flushReact();
const removeButtons = [...document.querySelectorAll("button")].filter(
(button) => button.textContent?.trim() === "Remove",
) as HTMLButtonElement[];
await act(async () => {
removeButtons[1]?.click();
});
await flushReact();
expect(document.body.textContent).toContain("Remove provider vault");
expect(document.body.textContent).toContain("from Paperclip only");
expect(document.body.textContent).toContain("does not delete");
expect(document.body.textContent).toContain("AWS Secrets Manager");
const confirmButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Remove from Paperclip"),
) as HTMLButtonElement | undefined;
await act(async () => {
confirmButton?.click();
});
await flushReact();
expect(mockSecretsApi.removeProviderConfig).toHaveBeenCalledWith("vault-aws");
expect(mockSecretsApi.disableProviderConfig).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("opens reference details from the secrets table count", async () => {
mockSecretsApi.list.mockResolvedValue([
{
@ -344,4 +484,173 @@ describe("Secrets page layout", () => {
root.unmount();
});
});
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await openAwsVaultDialog();
const discoveryButton = document.querySelector(
'[data-testid="aws-vault-discovery-button"]',
) as HTMLButtonElement | null;
expect(discoveryButton).not.toBeNull();
expect(discoveryButton?.disabled).toBe(true);
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement | null;
const prefixInput = document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement | null;
expect(regionInput).not.toBeNull();
await act(async () => {
setInputValue(regionInput!, "us-east-1");
setInputValue(prefixInput!, "paperclip");
});
await flushReact();
expect(discoveryButton?.disabled).toBe(false);
await act(async () => {
discoveryButton?.click();
});
await flushReact();
await flushReact();
expect(mockSecretsApi.providerConfigDiscoveryPreview).toHaveBeenCalledWith("company-1", {
provider: "aws_secrets_manager",
config: {
region: "us-east-1",
namespace: null,
secretNamePrefix: "paperclip",
kmsKeyId: null,
ownerTag: null,
environmentTag: null,
},
query: "paperclip",
pageSize: 25,
});
expect(document.body.textContent).toContain("AWS production");
const useValuesButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Use values"),
) as HTMLButtonElement | undefined;
await act(async () => {
useValuesButton?.click();
});
await flushReact();
expect((document.getElementById("vault-name") as HTMLInputElement).value).toBe("AWS production");
expect((document.getElementById("provider-vault-namespace") as HTMLInputElement).value).toBe("prod-use1");
expect((document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement).value).toBe("paperclip");
expect((document.getElementById("provider-vault-kms-key-id") as HTMLInputElement).value).toBe("alias/paperclip-secrets");
expect((document.getElementById("provider-vault-owner-tag") as HTMLInputElement).value).toBe("platform");
expect((document.getElementById("provider-vault-environment-tag") as HTMLInputElement).value).toBe("production");
expect(mockSecretsApi.createProviderConfig).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("shows AWS discovery errors without replacing manual vault form values", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
details: { code: "access_denied" },
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
const namespaceInput = document.getElementById("provider-vault-namespace") as HTMLInputElement;
await act(async () => {
setInputValue(regionInput, "us-west-2");
setInputValue(namespaceInput, "manual-prod");
});
await flushReact();
const discoveryButton = document.querySelector(
'[data-testid="aws-vault-discovery-button"]',
) as HTMLButtonElement | null;
await act(async () => {
discoveryButton?.click();
});
await flushReact();
await flushReact();
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
expect(regionInput.value).toBe("us-west-2");
expect(namespaceInput.value).toBe("manual-prod");
await act(async () => {
root.unmount();
});
});
it("shows an empty AWS discovery result without blocking manual entry", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
await act(async () => {
setInputValue(regionInput, "us-east-2");
});
await flushReact();
await act(async () => {
(document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click();
});
await flushReact();
await flushReact();
expect(document.body.textContent).toContain("No AWS vault metadata candidates found");
expect(regionInput.value).toBe("us-east-2");
await act(async () => {
root.unmount();
});
});
});