[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
23 changed files with 1602 additions and 13 deletions
+289
View File
@@ -29,6 +29,8 @@ import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
SecretProviderConfigDiscoveryCandidate,
SecretProviderConfigDiscoveryPreviewResult,
SecretAccessEvent,
SecretManagedMode,
SecretProvider,
@@ -325,6 +327,16 @@ function buildProviderVaultConfig(form: ProviderVaultForm): Record<string, unkno
}
}
function getAwsProviderVaultDiscoveryQuery(form: ProviderVaultForm): string | null {
return (
form.secretNamePrefix.trim() ||
form.namespace.trim() ||
form.environmentTag.trim() ||
form.ownerTag.trim() ||
null
);
}
export function getAwsManagedPathPreview(input: {
provider: SecretProviderDescriptor | null | undefined;
health: SecretProviderHealthResponse | null;
@@ -372,8 +384,12 @@ export function Secrets() {
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
const [removeVaultConfirm, setRemoveVaultConfirm] = useState<CompanySecretProviderConfig | null>(null);
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
const [vaultError, setVaultError] = useState<string | null>(null);
const [vaultDiscovery, setVaultDiscovery] =
useState<SecretProviderConfigDiscoveryPreviewResult | null>(null);
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Secrets" }]);
@@ -648,6 +664,24 @@ export function Secrets() {
},
});
const discoverVaultMutation = useMutation({
mutationFn: () =>
secretsApi.providerConfigDiscoveryPreview(selectedCompanyId!, {
provider: "aws_secrets_manager",
config: buildProviderVaultConfig(vaultForm),
query: getAwsProviderVaultDiscoveryQuery(vaultForm),
pageSize: 25,
}),
onSuccess: (preview) => {
setVaultDiscovery(preview);
setVaultDiscoveryError(null);
},
onError: (error) => {
setVaultDiscovery(null);
setVaultDiscoveryError(error instanceof ApiError ? error.message : (error as Error).message);
},
});
const disableVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
onSuccess: (updated) => {
@@ -663,6 +697,26 @@ export function Secrets() {
},
});
const removeVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.removeProviderConfig(id),
onSuccess: (removed) => {
pushToast({
title: "Provider vault removed",
body: `${removed.displayName} was removed from Paperclip only.`,
tone: "info",
});
setRemoveVaultConfirm(null);
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Remove failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
const defaultVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id),
onSuccess: (updated) => {
@@ -735,6 +789,8 @@ export function Secrets() {
setEditingVault(null);
setVaultForm(emptyProviderVaultForm(provider));
setVaultError(null);
setVaultDiscovery(null);
setVaultDiscoveryError(null);
setVaultDialogOpen(true);
}
@@ -742,9 +798,26 @@ export function Secrets() {
setEditingVault(config);
setVaultForm(providerVaultFormFromConfig(config));
setVaultError(null);
setVaultDiscovery(null);
setVaultDiscoveryError(null);
setVaultDialogOpen(true);
}
function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) {
if (candidate.provider !== "aws_secrets_manager") return;
const config = candidate.config as Record<string, unknown>;
setVaultForm((current) => ({
...current,
displayName: current.displayName.trim() ? current.displayName : candidate.displayName,
region: providerConfigValue(config, "region"),
namespace: providerConfigValue(config, "namespace"),
secretNamePrefix: providerConfigValue(config, "secretNamePrefix"),
kmsKeyId: providerConfigValue(config, "kmsKeyId"),
ownerTag: providerConfigValue(config, "ownerTag"),
environmentTag: providerConfigValue(config, "environmentTag"),
}));
}
if (!selectedCompanyId) {
return (
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
@@ -923,10 +996,12 @@ export function Secrets() {
onCreate={openCreateVault}
onEdit={openEditVault}
onDisable={(config) => disableVaultMutation.mutate(config.id)}
onRemove={(config) => setRemoveVaultConfirm(config)}
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
pendingActionId={
disableVaultMutation.variables ??
removeVaultMutation.variables ??
defaultVaultMutation.variables ??
healthVaultMutation.variables ??
null
@@ -1305,6 +1380,8 @@ export function Secrets() {
onChange={(event) => {
const provider = event.target.value as SecretProvider;
setVaultForm(emptyProviderVaultForm(provider));
setVaultDiscovery(null);
setVaultDiscoveryError(null);
}}
>
{PROVIDER_ORDER.map((provider) => (
@@ -1367,6 +1444,21 @@ export function Secrets() {
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
{!editingVault && vaultForm.provider === "aws_secrets_manager" ? (
<AwsProviderVaultDiscoveryPanel
form={vaultForm}
preview={vaultDiscovery}
error={vaultDiscoveryError}
loading={discoverVaultMutation.isPending}
onDiscover={() => {
setVaultDiscovery(null);
setVaultDiscoveryError(null);
discoverVaultMutation.mutate();
}}
onApply={applyVaultDiscoveryCandidate}
/>
) : null}
{vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-3 text-xs text-sky-700 dark:text-sky-300">
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until
@@ -1510,6 +1602,32 @@ export function Secrets() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(removeVaultConfirm)} onOpenChange={(open) => !open && setRemoveVaultConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove provider vault</DialogTitle>
<DialogDescription>
Removes <strong>{removeVaultConfirm?.displayName}</strong> from Paperclip only.{" "}
{removeVaultConfirm?.provider === "aws_secrets_manager"
? "This does not delete the remote AWS Secrets Manager vault, secrets, or any AWS data."
: "This does not delete any remote provider data."}{" "}
Secrets using this vault will lose the vault association until you assign another one.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveVaultConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => removeVaultConfirm && removeVaultMutation.mutate(removeVaultConfirm.id)}
disabled={removeVaultMutation.isPending}
>
{removeVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Remove from Paperclip
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1748,6 +1866,7 @@ export function ProviderVaultsTab({
onCreate,
onEdit,
onDisable,
onRemove,
onSetDefault,
onHealthCheck,
pendingActionId,
@@ -1760,6 +1879,7 @@ export function ProviderVaultsTab({
onCreate: (provider: SecretProvider) => void;
onEdit: (config: CompanySecretProviderConfig) => void;
onDisable: (config: CompanySecretProviderConfig) => void;
onRemove: (config: CompanySecretProviderConfig) => void;
onSetDefault: (config: CompanySecretProviderConfig) => void;
onHealthCheck: (config: CompanySecretProviderConfig) => void;
pendingActionId: string | null;
@@ -1840,6 +1960,7 @@ export function ProviderVaultsTab({
pending={pendingActionId === config.id}
onEdit={() => onEdit(config)}
onDisable={() => onDisable(config)}
onRemove={() => onRemove(config)}
onSetDefault={() => onSetDefault(config)}
onHealthCheck={() => onHealthCheck(config)}
/>
@@ -1858,6 +1979,7 @@ function ProviderVaultCard({
pending,
onEdit,
onDisable,
onRemove,
onSetDefault,
onHealthCheck,
}: {
@@ -1865,6 +1987,7 @@ function ProviderVaultCard({
pending: boolean;
onEdit: () => void;
onDisable: () => void;
onRemove: () => void;
onSetDefault: () => void;
onHealthCheck: () => void;
}) {
@@ -1936,6 +2059,16 @@ function ProviderVaultCard({
<Ban className="h-3.5 w-3.5 mr-1" />
Disable
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onRemove}
disabled={pending}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
</div>
</div>
);
@@ -2002,6 +2135,162 @@ function ProviderVaultFields({
);
}
function AwsProviderVaultDiscoveryPanel({
form,
preview,
error,
loading,
onDiscover,
onApply,
}: {
form: ProviderVaultForm;
preview: SecretProviderConfigDiscoveryPreviewResult | null;
error: string | null;
loading: boolean;
onDiscover: () => void;
onApply: (candidate: SecretProviderConfigDiscoveryCandidate) => void;
}) {
const canDiscover = Boolean(form.region.trim());
const warnings = preview?.warnings ?? [];
return (
<div className="space-y-3 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">AWS discovery</p>
<p className="text-xs text-muted-foreground">
Uses the current draft routing fields to inspect AWS Secrets Manager metadata. Values are not read.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDiscover}
disabled={!canDiscover || loading}
data-testid="aws-vault-discovery-button"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<Search className="h-3.5 w-3.5 mr-1" />
)}
Find existing AWS values
</Button>
</div>
{!canDiscover ? (
<p className="text-xs text-muted-foreground">Enter an AWS region before discovery.</p>
) : null}
{loading ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Searching AWS Secrets Manager metadata
</div>
) : null}
{error ? (
<div
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
role="alert"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
) : null}
{warnings.length > 0 ? (
<div className="space-y-1 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-amber-700 dark:text-amber-300">
{warnings.map((warning) => (
<div key={warning} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
{preview && preview.candidates.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
No AWS vault metadata candidates found. Manual entry is still available.
</div>
) : null}
{preview && preview.candidates.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3.5 w-3.5" />
<span>
{preview.candidates.length} candidate{preview.candidates.length === 1 ? "" : "s"} from{" "}
{preview.sampledSecretCount} sampled secret{preview.sampledSecretCount === 1 ? "" : "s"}
</span>
</div>
<div className="space-y-2" data-testid="aws-vault-discovery-candidates">
{preview.candidates.map((candidate, index) => (
<AwsProviderVaultDiscoveryCandidateRow
key={`${candidate.displayName}-${index}`}
candidate={candidate}
onApply={() => onApply(candidate)}
/>
))}
</div>
</div>
) : null}
</div>
);
}
function AwsProviderVaultDiscoveryCandidateRow({
candidate,
onApply,
}: {
candidate: SecretProviderConfigDiscoveryCandidate;
onApply: () => void;
}) {
const fieldSummary = [
providerConfigValue(candidate.config, "region"),
providerConfigValue(candidate.config, "namespace"),
providerConfigValue(candidate.config, "secretNamePrefix"),
].filter(Boolean);
return (
<div className="rounded-md border border-border bg-background p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium leading-snug">{candidate.displayName}</p>
<span className="text-xs text-muted-foreground">
{candidate.sampleCount} sample{candidate.sampleCount === 1 ? "" : "s"}
</span>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{fieldSummary.length > 0 ? fieldSummary.join(" / ") : "No stable namespace or prefix detected"}
</p>
{candidate.samples[0] ? (
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{candidate.samples[0].name}
</p>
) : null}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onApply}>
Use values
</Button>
</div>
{candidate.warnings.length > 0 ? (
<div className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{candidate.warnings.map((warning) => (
<div key={warning} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
);
}
function TextField({
label,
value,