[codex] Explain AWS secret creation failures in the UI (#9645)

## Thinking Path

> - Paperclip is the open source control plane operators use to manage
AI-agent companies.
> - Operators can store runtime secrets in provider vaults such as AWS
Secrets Manager.
> - The server already preserves sanitized AWS failure details,
including the failed operation, required IAM capability, region, and
safe recovery options.
> - The create-secret dialog reduced that structured response to a
generic message, leaving operators unable to understand or fix failed
AWS writes.
> - This pull request keeps the safe structured error through the UI and
presents concise, actionable diagnostics without exposing raw AWS
principals or account details.
> - The benefit is that operators can correct IAM access or link an
existing AWS secret immediately instead of debugging an opaque failure.

## Linked Issues or Issue Description

No public issue was found for this exact UI gap.

Bug report:
- What happened: creating a Paperclip-managed value in AWS Secrets
Manager could fail with a generic dialog error even though the API
returned safe, actionable provider details.
- Expected behavior: the dialog should identify the AWS operation,
required IAM capability, region, provider vault, and safe alternative
while keeping raw cloud-provider details redacted.
- Steps to reproduce: configure an AWS Secrets Manager provider vault
without `secretsmanager:CreateSecret`, then create a Paperclip-managed
secret using that vault.
- Paperclip version/commit: current `master` before this PR.
- Deployment mode: Paperclip server with an AWS Secrets Manager provider
vault.

Related prior server-side propagation work:
- Refs #9161

## What Changed

- Preserve the structured `ApiError` returned by failed create-secret
mutations instead of reducing it to a string.
- Render AWS-specific, sanitized diagnostics with the required IAM
capability, region, provider vault, operation, and external-reference
recovery option.
- Add a full dialog render regression test that verifies actionable
details appear and raw AWS ARN/account information does not.

## Verification

- `pnpm exec vitest run ui/src/pages/Secrets.render.test.tsx` — 1 file
passed, 15 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — all gates clean.
- `git diff --check public-gh/master...HEAD` — passed.

### Visual Verification

QA verified both states in Chromium at head `357cd2271` using the real
`Secrets` component and confirmed that raw AWS account, ARN,
assumed-role, and provider exception details are absent from the
rendered DOM.

**AWS access-denied diagnostics**

![AWS access-denied
diagnostics](https://pages.paperclip.ing/pap-14130-secret-error/aws-access-denied.png)

**Generic non-AWS fallback**

![Generic non-AWS
fallback](https://pages.paperclip.ing/pap-14130-secret-error/generic-error.png)

## Risks

Low risk. The change only affects failed create-secret presentation in
the UI; successful secret creation, API contracts, schema, and
migrations are unchanged. The structured details are server-sanitized,
and the regression test confirms raw AWS principal/account data is not
rendered.

> 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 `gpt-5.4` via Codex CLI, with tool-enabled repository editing,
shell execution, Git, GitHub, and Paperclip API access. Reasoning mode
and exact context-window size were not exposed by the 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 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
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [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-07-15 21:47:07 -05:00 committed by GitHub
parent 8368fb30b0
commit 8eff54bc47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 240 additions and 3 deletions

View File

@ -295,6 +295,12 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
function setSelectValue(select: HTMLSelectElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, "value")?.set;
setter?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
}
async function openAwsVaultDialog() {
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
@ -851,6 +857,131 @@ describe("Secrets page layout", () => {
});
});
it("explains AWS managed secret creation failures with actionable safe details", async () => {
const rawProviderMessage =
"AccessDeniedException: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
mockSecretsApi.create.mockRejectedValueOnce(
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
details: {
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret.create",
providerConfigId: "vault-aws",
region: "us-east-1",
credentialPath: "Paperclip server runtime/provider credential path",
requiredCapability: "secretsmanager:CreateSecret",
actionableMessage:
"AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault.",
safeAlternative:
"If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value.",
},
}),
);
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 newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
) as HTMLButtonElement | undefined;
await act(async () => {
newSecretButton?.click();
});
await flushReact();
await act(async () => {
setInputValue(document.getElementById("new-secret-name") as HTMLInputElement, "AWS test token");
setSelectValue(document.getElementById("new-secret-provider") as HTMLSelectElement, "aws_secrets_manager");
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value");
});
await flushReact();
const createButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Create secret",
) as HTMLButtonElement | undefined;
await act(async () => {
createButton?.click();
});
await flushReact();
await flushReact();
const errorBanner = document.querySelector('[data-testid="secret-create-error"]');
expect(errorBanner?.textContent).toContain("AWS secret creation needs CreateSecret permission");
expect(errorBanner?.textContent).toContain("secretsmanager:CreateSecret");
expect(errorBanner?.textContent).toContain("us-east-1");
expect(errorBanner?.textContent).toContain("link it as an external reference");
expect(errorBanner?.textContent).toContain("vault-aws");
expect(errorBanner?.textContent).not.toContain(rawProviderMessage);
expect(errorBanner?.textContent).not.toContain("123456789012");
await act(async () => {
root.unmount();
});
});
it("renders generic secret creation failures with a stable selector", async () => {
mockSecretsApi.create.mockRejectedValueOnce(new ApiError("Secret creation failed", 500, null));
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 newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
) as HTMLButtonElement | undefined;
await act(async () => {
newSecretButton?.click();
});
await flushReact();
await act(async () => {
setInputValue(document.getElementById("new-secret-name") as HTMLInputElement, "Failed token");
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value");
});
await flushReact();
const createButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Create secret",
) as HTMLButtonElement | undefined;
await act(async () => {
createButton?.click();
});
await flushReact();
await flushReact();
const errorBanner = document.querySelector('[data-testid="secret-create-error"]');
expect(errorBanner?.textContent).toBe("Secret creation failed");
await act(async () => {
root.unmount();
});
});
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
const root = createRoot(container);

View File

@ -625,7 +625,7 @@ export function Secrets() {
provider: "local_encrypted" as SecretProvider,
providerConfigId: "",
});
const [createError, setCreateError] = useState<string | null>(null);
const [createError, setCreateError] = useState<unknown>(null);
const [rotateOpen, setRotateOpen] = useState(false);
const [rotateValue, setRotateValue] = useState("");
const [rotateExternalRef, setRotateExternalRef] = useState("");
@ -955,7 +955,7 @@ export function Secrets() {
}
},
onError: (error) => {
setCreateError(error instanceof ApiError ? error.message : (error as Error).message);
setCreateError(error);
},
});
@ -2301,7 +2301,13 @@ export function Secrets() {
</div>
</>
)}
{createError ? <p className="text-xs text-destructive">{createError}</p> : null}
{createError ? (
<SecretCreateError
error={createError}
provider={createForm.provider}
providerConfigId={createForm.providerConfigId || null}
/>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
@ -3384,6 +3390,106 @@ function AwsProviderVaultDiscoveryError({
);
}
function SecretCreateError({
error,
provider,
providerConfigId,
}: {
error: unknown;
provider: SecretProvider;
providerConfigId: string | null;
}) {
const details = apiErrorDetails(error);
const message = readableErrorMessage(error);
const isAwsCreateError =
details?.provider === "aws_secrets_manager" && details.operation === "secret.create";
const isAccessDenied = isAwsCreateError && details.code === "access_denied";
const safeDetails = {
message,
status: error instanceof ApiError ? error.status : undefined,
provider: details?.provider ?? provider,
operation: details?.operation ?? "secret.create",
providerConfigId: details?.providerConfigId ?? providerConfigId ?? "deployment-default",
region: details?.region,
code: details?.code,
requiredCapability: details?.requiredCapability,
credentialPath: details?.credentialPath,
safeAlternative: details?.safeAlternative,
};
const detailsText = JSON.stringify(safeDetails, null, 2);
if (!isAwsCreateError) {
return (
<p className="text-xs text-destructive" role="alert" data-testid="secret-create-error">
{message}
</p>
);
}
return (
<div
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
role="alert"
data-testid="secret-create-error"
>
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<div>
<p className="font-medium">
{isAccessDenied ? "AWS secret creation needs CreateSecret permission" : "AWS secret creation failed"}
</p>
<p className="mt-1 leading-relaxed text-destructive/85">
{details?.actionableMessage ?? message}
</p>
</div>
{details?.safeAlternative ? (
<p className="leading-relaxed text-destructive/85">{details.safeAlternative}</p>
) : null}
<dl className="grid gap-1 text-destructive/80 sm:grid-cols-2">
{details?.requiredCapability ? (
<div>
<dt className="font-medium">Required IAM capability</dt>
<dd className="font-mono">{details.requiredCapability}</dd>
</div>
) : null}
{details?.region ? (
<div>
<dt className="font-medium">Region</dt>
<dd>{details.region}</dd>
</div>
) : null}
<div>
<dt className="font-medium">Provider vault</dt>
<dd className="break-all">{details?.providerConfigId ?? providerConfigId ?? "Deployment default"}</dd>
</div>
<div>
<dt className="font-medium">Operation</dt>
<dd>{details?.operation ?? "secret.create"}</dd>
</div>
</dl>
<div className="rounded-md border border-destructive/20 bg-background/70 p-2 text-foreground">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Safe request/error details</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void navigator.clipboard?.writeText(detailsText)}
>
Copy
</Button>
</div>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words font-mono text-(length:--text-micro) leading-relaxed">
{detailsText}
</pre>
</div>
</div>
</div>
</div>
);
}
function AwsProviderVaultDiscoveryCandidateRow({
candidate,
onApply,