[codex] Preserve AWS secret provider error classification (#10946)

## Thinking Path

> - Paperclip is the open source control plane operators use to manage
AI-agent companies.
> - Operators can store managed secret values in AWS Secrets Manager
provider vaults.
> - The AWS JSON gateway already classifies safe provider failures such
as access denial, expired credentials, throttling, and invalid requests.
> - The outer provider wrapper was re-normalizing those typed failures,
which collapsed an AWS access denial into a generic provider error
before the service could produce actionable guidance.
> - Missing runtime credentials also need to be recognized as an access
problem so operators receive the correct recovery path.
> - This pull request preserves typed provider errors, expands
credential-related classification, and clarifies the safe credential/IAM
remediation text.
> - The benefit is that failed AWS secret creation tells operators
whether to restore runtime credentials or grant the required capability
without exposing AWS account or principal details.

## Linked Issues or Issue Description

No public issue was found for this exact remaining server-side
classification gap.

Bug report:
- What happened: an AWS Secrets Manager access-denied response could be
classified correctly by the JSON gateway and then collapsed to
`provider_error` by the outer provider wrapper; missing runtime
credentials were also reported generically.
- Expected behavior: safe typed AWS failures should retain their
classification, and credential resolution failures should surface as
access-denied guidance.
- Steps to reproduce: configure an AWS provider vault with invalid or
missing runtime credentials, or without `secretsmanager:CreateSecret`,
then create a Paperclip-managed secret.
- Paperclip version/commit: current `master` before this PR.
- Deployment mode: Paperclip server with an AWS Secrets Manager provider
vault.

Related prior work:
- Refs #9161
- Refs #9645

## What Changed

- Preserve `SecretProviderClientError` instances returned by the AWS
JSON gateway instead of wrapping them as generic provider failures.
- Classify missing, expired, invalid, or incorrectly signed AWS runtime
credentials as access-denied failures.
- Clarify sanitized remediation guidance to cover both credential
resolution and `secretsmanager:CreateSecret` authorization.
- Add regression coverage for gateway access-denied responses, missing
runtime credentials, and the service-level actionable message.

## Verification

- `pnpm exec vitest run
server/src/__tests__/aws-secrets-manager-provider.test.ts
server/src/__tests__/secrets-service.test.ts` — changed provider tests
passed; the service file was skipped in the first no-DB focused run.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm -r typecheck` — passed after a frozen-lockfile dependency
refresh.
- `pnpm build` — passed.
- `pnpm test:run` — 314 files / 3,443 tests passed; one unrelated
existing test failed in `plugin-orchestration-apis.test.ts` because it
observed `heartbeat.scheduling_suppressed` instead of `issue_commented`.
The same failure reproduced in isolation and none of this PR's four
changed files participate in that test.
- `git diff --check public-gh/master...HEAD` — passed.

## Risks

Low risk. The change affects only error classification and sanitized
failure guidance for AWS Secrets Manager operations. Successful secret
writes, schemas, migrations, API shapes, and stored secret material are
unchanged. The regression tests assert safe classifications while
keeping raw AWS account and principal details out of service responses.

> 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 family (exact runtime build and context-window size
were not exposed), with reasoning, repository editing, shell execution,
Git, GitHub, and test execution enabled.

## 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 (not
applicable: no documented contract changed)
- [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>
Co-authored-by: commitperclip[bot] <284812584+commitperclip[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-26 20:54:18 -07:00
1 parent 05218571c3
commit 585d37d4f2
4 files changed
+108 -2

No files matched your search

@@ -231,6 +231,102 @@ describe("awsSecretsManagerProvider", () => {
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
it("preserves classified AWS create errors returned by the JSON gateway", async () => {
delete process.env.AWS_PROFILE;
delete process.env.AWS_DEFAULT_PROFILE;
delete process.env.AWS_CONFIG_FILE;
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
delete process.env.AWS_SDK_LOAD_CONFIG;
process.env.AWS_ACCESS_KEY_ID = "AKIA_CREATE_ERROR_TEST";
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
__type: "AccessDeniedException",
message:
"User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:CreateSecret",
}),
{ status: 400 },
),
);
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-west-1",
endpoint: "https://secretsmanager.us-west-1.amazonaws.com",
deploymentId: "prod",
prefix: "paperclip",
kmsKeyId: null,
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
await expect(
provider.createSecret({
value: "super-secret-value",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
}),
).rejects.toMatchObject({
code: "access_denied",
status: 403,
operation: "CreateSecret",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: expect.stringContaining("AccessDeniedException"),
});
});
it("classifies missing runtime AWS credentials as access denied", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: null,
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("CredentialsProviderError: Could not load credentials from any providers");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
await expect(
provider.createSecret({
value: "super-secret-value",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
}),
).rejects.toMatchObject({
code: "access_denied",
status: 403,
rawMessage: "CredentialsProviderError: Could not load credentials from any providers",
});
});
it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
@@ -3409,6 +3409,9 @@ describeEmbeddedPostgres("secretService", () => {
requiredCapability: "secretsmanager:CreateSecret",
},
});
expect((thrown as HttpError).details?.actionableMessage).toContain(
"Ensure the Paperclip server runtime can resolve valid AWS credentials",
);
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
expect(JSON.stringify(thrown)).not.toContain("123456789012");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
@@ -840,7 +840,11 @@ function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSec
function classifyAwsProviderError(message: string): SecretProviderClientErrorCode {
if (/ResourceExistsException|AlreadyExists/i.test(message)) return "conflict";
if (/ResourceNotFoundException|NotFound/i.test(message)) return "not_found";
if (/AccessDeniedException|AccessDenied|UnrecognizedClientException|InvalidClientTokenId|not authorized/i.test(message)) {
if (
/AccessDeniedException|AccessDenied|CredentialsProviderError|Could not load credentials|credential provider chain|UnrecognizedClientException|InvalidClientTokenId|ExpiredTokenException|InvalidSignatureException|SignatureDoesNotMatch|not authorized/i.test(
message,
)
) {
return "access_denied";
}
if (/Throttl|TooManyRequests|RequestLimitExceeded|Rate exceeded/i.test(message)) return "throttled";
@@ -870,6 +874,9 @@ function awsProviderSafeMessage(code: SecretProviderClientErrorCode): string {
}
function normalizeAwsError(operation: string, error: unknown): never {
if (error instanceof SecretProviderClientError) {
throw error;
}
const rawMessage = error instanceof Error ? error.message : String(error);
const code = classifyAwsProviderError(rawMessage);
throw new SecretProviderClientError({
+1 -1
View File
@@ -505,7 +505,7 @@ function safeRemoteProviderErrorDetails(
if (context.operation === "secret.create") {
details.requiredCapability = "secretsmanager:CreateSecret";
details.actionableMessage =
"AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault. If the vault config uses a KMS key, the runtime credentials also need KMS write permissions for that key.";
"Ensure the Paperclip server runtime can resolve valid AWS credentials, then grant them secretsmanager:CreateSecret in the selected region for this provider vault. If the vault config uses a KMS key, those credentials also need KMS write permissions for that key.";
details.safeAlternative =
"If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value.";
} else if (context.operation === "secret.rotate") {