From 2619eb092edbaf729d3a3d73713e096f05443e35 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 26 May 2026 09:29:46 +0000 Subject: [PATCH] fix(ai): honored bedrock bearer token precedence Bedrock now sends AWS_BEARER_TOKEN_BEDROCK as Authorization: Bearer before resolving SigV4 credentials, so failing profile credential_process hooks cannot block bearer-token requests. Added regression coverage for a default profile credential_process failure with AWS_BEARER_TOKEN_BEDROCK set. Fixes #1399 --- docs/environment-variables.md | 2 +- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/amazon-bedrock.ts | 52 ++++++----- packages/ai/src/stream.ts | 4 +- packages/ai/test/issue-1399-repro.test.ts | 96 +++++++++++++++++++++ 5 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 packages/ai/test/issue-1399-repro.test.ts diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 01ec224b7..87a665480 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -131,7 +131,7 @@ When `CLAUDE_CODE_USE_FOUNDRY` is enabled, Anthropic requests switch to Foundry | `AWS_DEFAULT_REGION` | Fallback if `AWS_REGION` unset | | `AWS_PROFILE` | Enables named profile auth path | | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | Enables IAM key auth path | -| `AWS_BEARER_TOKEN_BEDROCK` | Enables bearer token auth path | +| `AWS_BEARER_TOKEN_BEDROCK` | Highest-precedence bearer token auth path; skips AWS profile/credential-chain lookup when set | | `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` / `AWS_CONTAINER_CREDENTIALS_FULL_URI` | Enables ECS task credential path | | `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` | Enables web identity auth path | | `AWS_BEDROCK_SKIP_AUTH` | If `1`, injects dummy credentials (proxy/non-auth scenarios) | diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ea61a96fc..205b83498 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -26,6 +26,7 @@ ### Fixed - Dropped truncated, thinking-only assistant turns with only `thinking`/`redacted_thinking` blocks and no `text` or `tool` content during message transformation, preventing Anthropic requests from sending consecutive assistant messages after a `max_tokens`/`error`/`aborted` interruption +- Fixed Amazon Bedrock bearer-token authentication to honor `AWS_BEARER_TOKEN_BEDROCK` before resolving AWS profiles or running `credential_process`, matching Bedrock API-key precedence. ([#1399](https://github.com/can1357/oh-my-pi/issues/1399)) - Updated `isRetryableError` to treat Bun HTTP/2 transport errors (`HTTP2StreamReset`, `HTTP2RefusedStream`) as retryable so transient stream-reset failures can be retried - Fixed Codex WebSocket streaming to recover from stalled sessions by falling back to SSE when the first event or subsequent progress is delayed beyond the configured websocket timeout - Fixed expired OAuth handling so provider-level paths no longer attempt direct token refresh calls for expired credentials and instead rely on `AuthStorage` for rotation diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index bf74b62c8..2122500d1 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -42,6 +42,8 @@ export type BedrockThinkingDisplay = "summarized" | "omitted"; export interface BedrockOptions extends StreamOptions { region?: string; profile?: string; + /** Amazon Bedrock API key sent as `Authorization: Bearer`, ahead of SigV4 credential resolution. */ + bearerToken?: string; toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; /* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-reasoning.html for supported models. */ reasoning?: Effort; @@ -225,34 +227,40 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( body: commandInput, }; - let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }; - if ($flag("AWS_BEDROCK_SKIP_AUTH")) { - credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" }; - } else { - credentials = await resolveAwsCredentials({ - profile: options.profile, - region, - signal: options.signal, - }); - } - const bodyText = JSON.stringify(commandInput); const body = new TextEncoder().encode(bodyText); const baseHeaders: Record = { "content-type": "application/json", accept: "application/vnd.amazon.eventstream", }; - const signed = await signRequest({ - method: "POST", - host, - path: urlPath, - body, - region, - service: "bedrock", - credentials, - headers: baseHeaders, - }); - const requestHeaders: Record = { ...baseHeaders, ...signed }; + + const bearerToken = options.bearerToken || options.apiKey || $env.AWS_BEARER_TOKEN_BEDROCK; + let requestHeaders: Record; + if (bearerToken) { + requestHeaders = { ...baseHeaders, Authorization: `Bearer ${bearerToken}` }; + } else { + let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }; + if ($flag("AWS_BEDROCK_SKIP_AUTH")) { + credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" }; + } else { + credentials = await resolveAwsCredentials({ + profile: options.profile, + region, + signal: options.signal, + }); + } + const signed = await signRequest({ + method: "POST", + host, + path: urlPath, + body, + region, + service: "bedrock", + credentials, + headers: baseHeaders, + }); + requestHeaders = { ...baseHeaders, ...signed }; + } const response = await fetchWithRetry(url, { method: "POST", diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 6a5e77ac7..01e97f78b 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -126,9 +126,9 @@ const serviceProviderMap: Record = { } }, // Amazon Bedrock supports multiple credential sources: - // 1. AWS_PROFILE - named profile from ~/.aws/credentials + // 1. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token) // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys - // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token) + // 3. AWS_PROFILE - named profile from ~/.aws/credentials // 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials // 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity "amazon-bedrock": () => { diff --git a/packages/ai/test/issue-1399-repro.test.ts b/packages/ai/test/issue-1399-repro.test.ts new file mode 100644 index 000000000..80d68d55a --- /dev/null +++ b/packages/ai/test/issue-1399-repro.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { hookFetch } from "@oh-my-pi/pi-utils"; +import { streamBedrock } from "../src/providers/amazon-bedrock"; +import { clearAwsCredentialCache } from "../src/providers/aws-credentials"; +import type { Context, Model } from "../src/types"; + +const model: Model<"bedrock-converse-stream"> = { + id: "zai.glm-5", + name: "GLM-5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131_072, + maxTokens: 16_384, +}; + +const context: Context = { + systemPrompt: [], + messages: [{ role: "user", content: "say hi", timestamp: Date.now() }], +}; + +const awsEnvKeys = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_EC2_METADATA_DISABLED", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_BEDROCK_SKIP_AUTH", +] as const; + +describe("issue #1399: Bedrock bearer token precedence", () => { + it("uses AWS_BEARER_TOKEN_BEDROCK without invoking profile credential_process", async () => { + const previous = new Map(); + for (const key of awsEnvKeys) previous.set(key, process.env[key]); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bedrock-auth-")); + try { + const configPath = path.join(tempDir, "config"); + await Bun.write( + configPath, + [ + "[default]", + "region = us-west-2", + "credential_process = /bin/sh -c 'echo should-not-run >&2; exit 17'", + "", + ].join("\n"), + ); + + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + delete process.env.AWS_PROFILE; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_BEDROCK_SKIP_AUTH; + process.env.AWS_REGION = "us-west-2"; + process.env.AWS_CONFIG_FILE = configPath; + process.env.AWS_SHARED_CREDENTIALS_FILE = path.join(tempDir, "credentials"); + process.env.AWS_EC2_METADATA_DISABLED = "true"; + process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-api-key"; + clearAwsCredentialCache(); + + let requestHeaders: Headers | undefined; + using _hook = hookFetch((_input, init) => { + requestHeaders = new Headers(init?.headers); + return new Response('{"message":"unauthorized"}', { status: 401 }); + }); + + const result = await streamBedrock(model, context, {}).result(); + + expect(requestHeaders?.get("authorization")).toBe("Bearer bedrock-api-key"); + expect(requestHeaders?.has("x-amz-date")).toBe(false); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("Bedrock HTTP 401"); + expect(result.errorMessage).not.toContain("credential_process"); + } finally { + for (const key of awsEnvKeys) { + const value = previous.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearAwsCredentialCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +});