[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**  **Generic non-AWS fallback**  ## 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:
parent
8368fb30b0
commit
8eff54bc47
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue