From c7e5289fd20d991b466afa8c80cd0ba06ebd8eb3 Mon Sep 17 00:00:00 2001 From: wirjo Date: Thu, 23 Apr 2026 07:45:26 +1000 Subject: [PATCH] fix: propagate AWS SDK auth sentinel for IMDS/instance role Bedrock auth (#68964) * fix: propagate AWS SDK auth sentinel for IMDS/instance role Bedrock auth When Bedrock auth resolves via AWS SDK default credential chain (IMDS, ECS task role) with no explicit API key, the auth controller returned early without calling setRuntimeApiKey(). This left pi's authStorage unaware that the provider is authenticated, causing 'No API key found for amazon-bedrock' errors. Now, when mode is 'aws-sdk' and no explicit API key is available: 1. Try prepareProviderRuntimeAuth to resolve runtime credentials 2. If that returns a real apiKey, use it with auth refresh scheduling 3. Otherwise inject a '__aws_sdk_auth__' sentinel so pi's hasConfiguredAuth() passes and the AWS SDK handles request signing This is a focused fix in auth-controller.ts only, avoiding the risky model-auth-runtime-shared.ts changes that could re-introduce the fake-apiKey injection pattern on ECS (see prior regressions #49891, #50699, #54274). Fixes #62995 * fix(pi-auth): clean up aws-sdk sentinel fallback * docs(changelog): note aws-sdk Bedrock auth fix --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../run/auth-controller.test.ts | 125 +++++++++++++++++- .../pi-embedded-runner/run/auth-controller.ts | 44 ++++++ 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdb6f01201..02a80ec437c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo. - Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only. - Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci. - Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc. diff --git a/src/agents/pi-embedded-runner/run/auth-controller.test.ts b/src/agents/pi-embedded-runner/run/auth-controller.test.ts index 748cbeb07b8..17636ab40af 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.test.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.test.ts @@ -88,6 +88,7 @@ function createMutableEmbeddedRunAuthController(params: { harness: MutableAuthControllerHarness; setRuntimeApiKey: RuntimeApiKeySetter; profileCandidates?: string[]; + warn?: (message: string) => void; }) { return createEmbeddedRunAuthController({ config: undefined, @@ -135,7 +136,7 @@ function createMutableEmbeddedRunAuthController(params: { log: { debug: () => undefined, info: () => undefined, - warn: () => undefined, + warn: params.warn ?? (() => undefined), }, }); } @@ -372,4 +373,126 @@ describe("createEmbeddedRunAuthController", () => { vi.useRealTimers(); } }); + + describe("aws-sdk auth without explicit API key (IMDS / instance role)", () => { + it("injects runtime auth when prepareProviderRuntimeAuth resolves credentials", async () => { + const harness = createMutableAuthControllerHarness(); + const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); + + mocks.getApiKeyForModel.mockResolvedValue({ + apiKey: undefined, + mode: "aws-sdk", + source: "aws-sdk default chain", + }); + mocks.prepareProviderRuntimeAuth.mockResolvedValue({ + apiKey: "imds-runtime-token", + expiresAt: Date.now() + 3600_000, + }); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey, + profileCandidates: [undefined as unknown as string], + }); + + await controller.initializeAuthProfile(); + + expect(setRuntimeApiKey).toHaveBeenCalledWith("custom-openai", "imds-runtime-token"); + expect(harness.runtimeAuthState).toMatchObject({ + sourceApiKey: "__aws_sdk_auth__", + authMode: "aws-sdk", + }); + expect(harness.runtimeAuthState?.expiresAt).toBeGreaterThan(Date.now()); + controller.stopRuntimeAuthRefreshTimer(); + }); + + it("injects sentinel when prepareProviderRuntimeAuth returns no apiKey", async () => { + const harness = createMutableAuthControllerHarness(); + const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); + + mocks.getApiKeyForModel.mockResolvedValue({ + apiKey: undefined, + mode: "aws-sdk", + source: "aws-sdk default chain", + }); + mocks.prepareProviderRuntimeAuth.mockResolvedValue(null); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey, + profileCandidates: [undefined as unknown as string], + }); + + await controller.initializeAuthProfile(); + + expect(setRuntimeApiKey).toHaveBeenCalledWith("custom-openai", "__aws_sdk_auth__"); + expect(harness.runtimeAuthState).toBeNull(); + }); + + it("clears any stale refresh timer before sentinel injection", async () => { + vi.useFakeTimers(); + try { + const harness = createMutableAuthControllerHarness(); + const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); + + harness.runtimeAuthState = { + generation: 1, + sourceApiKey: "__aws_sdk_auth__", + authMode: "aws-sdk", + refreshTimer: setTimeout(() => undefined, 60_000), + }; + + mocks.getApiKeyForModel.mockResolvedValue({ + apiKey: undefined, + mode: "aws-sdk", + source: "aws-sdk default chain", + }); + mocks.prepareProviderRuntimeAuth.mockResolvedValue(null); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey, + profileCandidates: [undefined as unknown as string], + }); + + await controller.initializeAuthProfile(); + + expect(setRuntimeApiKey).toHaveBeenCalledWith("custom-openai", "__aws_sdk_auth__"); + expect(harness.runtimeAuthState).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("injects sentinel when prepareProviderRuntimeAuth throws", async () => { + const harness = createMutableAuthControllerHarness(); + const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); + const warn = vi.fn<(message: string) => void>(); + + mocks.getApiKeyForModel.mockResolvedValue({ + apiKey: undefined, + mode: "aws-sdk", + source: "aws-sdk default chain", + }); + mocks.prepareProviderRuntimeAuth.mockRejectedValue(new Error("No runtime auth plugin")); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey, + profileCandidates: [undefined as unknown as string], + warn, + }); + + await controller.initializeAuthProfile(); + + expect(setRuntimeApiKey).toHaveBeenCalledWith("custom-openai", "__aws_sdk_auth__"); + expect(harness.runtimeAuthState).toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "prepareProviderRuntimeAuth failed for custom-openai, falling back to sentinel: No runtime auth plugin", + ), + ); + }); + }); }); diff --git a/src/agents/pi-embedded-runner/run/auth-controller.ts b/src/agents/pi-embedded-runner/run/auth-controller.ts index 10f92b3a590..053b026d2dc 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.ts @@ -368,6 +368,50 @@ export function createEmbeddedRunAuthController(params: { `No API key resolved for provider "${runtimeModel.provider}" (auth mode: ${apiKeyInfo.mode}).`, ); } + // AWS SDK auth via IMDS / instance role / ECS task role: no explicit API + // key is available but the SDK default credential chain can resolve + // credentials at runtime. We must still call setRuntimeApiKey so that + // pi's authStorage considers the provider authenticated. Try + // prepareProviderRuntimeAuth first (it can sign requests and return a + // short-lived token); fall back to a sentinel value when the provider + // plugin does not implement runtime auth preparation. + const runtimeModel = params.getRuntimeModel(); + const AWS_SDK_AUTH_SENTINEL = "__aws_sdk_auth__"; + try { + const preparedAuth = await prepareRuntimeAuthForModel({ + runtimeModel, + apiKey: AWS_SDK_AUTH_SENTINEL, + authMode: apiKeyInfo.mode, + profileId: apiKeyInfo.profileId, + }); + applyPreparedRuntimeRequestOverrides({ runtimeModel, preparedAuth: preparedAuth ?? {} }); + if (preparedAuth?.apiKey) { + clearRuntimeAuthRefreshTimer(); + params.authStorage.setRuntimeApiKey(runtimeModel.provider, preparedAuth.apiKey); + params.setRuntimeAuthState({ + generation: nextRuntimeAuthGeneration(), + sourceApiKey: AWS_SDK_AUTH_SENTINEL, + authMode: apiKeyInfo.mode, + profileId: resolvedProfileId, + expiresAt: preparedAuth.expiresAt, + }); + if (preparedAuth.expiresAt) { + scheduleRuntimeAuthRefresh(); + } + params.setLastProfileId(resolvedProfileId); + return; + } + } catch (error) { + params.log.warn( + `prepareProviderRuntimeAuth failed for ${runtimeModel.provider}, falling back to sentinel: ${formatErrorMessage(error)}`, + ); + } + // No runtime auth plugin resolved a real credential. Inject the + // sentinel so pi's hasConfiguredAuth() passes and the AWS SDK default + // credential chain handles actual request signing. + clearRuntimeAuthRefreshTimer(); + params.authStorage.setRuntimeApiKey(runtimeModel.provider, AWS_SDK_AUTH_SENTINEL); + params.setRuntimeAuthState(null); params.setLastProfileId(resolvedProfileId); return; }