From 8eff54bc4720549197e9106cbd253d38f38ac58c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:47:07 -0500 Subject: [PATCH] [codex] Explain AWS secret creation failures in the UI (#9645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ui/src/pages/Secrets.render.test.tsx | 131 +++++++++++++++++++++++++++ ui/src/pages/Secrets.tsx | 112 ++++++++++++++++++++++- 2 files changed, 240 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index b21c96d7bb..a983b62932 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index fd37743609..c3beceea05 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -625,7 +625,7 @@ export function Secrets() { provider: "local_encrypted" as SecretProvider, providerConfigId: "", }); - const [createError, setCreateError] = useState(null); + const [createError, setCreateError] = useState(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() { )} - {createError ?

{createError}

: null} + {createError ? ( + + ) : null} + +
+              {detailsText}
+            
+ + + + + ); +} + function AwsProviderVaultDiscoveryCandidateRow({ candidate, onApply,