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; }