fix(amazon-bedrock-mantle): refresh IAM bearer token via resolveConfigApiKey cache lookup (#68903)

* fix(amazon-bedrock-mantle): refresh IAM bearer token via resolveConfigApiKey cache lookup

The Mantle plugin generates a bearer token from IAM credentials at discovery
time and bakes it as a static string into the provider config. After the
token's cache TTL expires (~1hr), requests fail because resolveConfigApiKey
only handled the explicit AWS_BEARER_TOKEN_BEDROCK env var case.

Fix: expose getCachedIamToken() as a sync read from the existing iamTokenCache,
and wire it into resolveConfigApiKey as a fallback when no explicit env var is
set. The catalog.run still generates/refreshes the token on discovery; this
change ensures the cached token is served at auth resolution time.

Fixes #68900

* fix(amazon-bedrock-mantle): refresh runtime IAM bearer auth

* docs(changelog): note Mantle IAM refresh

* fix(agents): apply runtime auth in simple completion

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
wirjo
2026-04-23 08:52:24 +10:00
committed by GitHub
parent 2321d67263
commit 420c96e7aa
7 changed files with 243 additions and 10 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.
- Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.
- Telegram/sandbox: keep Telegram bot DMs on per-account sender session keys even when `session.dmScope=main`, so sandbox/tool policy can distinguish Telegram-originated direct chats from the agent main session.
- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653.

View File

@@ -1,9 +1,12 @@
export {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";

View File

@@ -2,11 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveMantleBearerToken,
resolveImplicitMantleProvider,
resolveMantleRuntimeBearerToken,
} from "./api.js";
const mocks = vi.hoisted(() => ({
@@ -80,7 +83,7 @@ describe("bedrock mantle discovery", () => {
let now = 1000;
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
now += 1800_000; // 30 min — within 1hr cache TTL
now += 1800_000; // 30 min — within 2hr cache TTL
const t2 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
expect(t1).toEqual(t2);
@@ -118,6 +121,33 @@ describe("bedrock mantle discovery", () => {
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
});
it("getCachedIamToken returns cached token when valid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
// Generate a token to populate the cache
await generateBearerTokenFromIam({ region: "us-east-1" });
// Sync read should return the cached token
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
});
it("getCachedIamToken returns undefined when cache is empty", () => {
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("getCachedIamToken returns undefined when cache is expired", async () => {
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
// Generate with a time far in the past so it's already expired
await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
// The cache entry exists but expiresAt is 1000 + 3600000 = 3601000
// Current Date.now() is way past that, so it should be expired
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
@@ -383,7 +413,7 @@ describe("bedrock mantle discovery", () => {
});
expect(provider).not.toBeNull();
expect(provider?.apiKey).toBe("bedrock-api-key-iam");
expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER);
expect(tokenProvider).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
@@ -395,6 +425,49 @@ describe("bedrock mantle discovery", () => {
);
});
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
});
await expect(
resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 2000,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-runtime",
expiresAt: 1000 + 7200_000,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("generates a fresh Mantle runtime IAM token when the cache is cold", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-fresh"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
await expect(
resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 5000,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-fresh",
expiresAt: 5000 + 7200_000,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("returns null for unsupported regions", async () => {
const provider = await resolveImplicitMantleProvider({
env: {

View File

@@ -18,6 +18,7 @@ const DEFAULT_COST = {
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
// ---------------------------------------------------------------------------
// Mantle region & endpoint helpers
@@ -69,7 +70,22 @@ export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env):
/** Token cache for IAM-derived bearer tokens, keyed by region. */
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 12h)
const IAM_TOKEN_TTL_MS = 7200_000; // Matches the 2h token lifetime we request below.
function resolveMantleRegion(env: NodeJS.ProcessEnv): string {
return env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
}
function getCachedIamTokenEntry(
region: string,
now: number = Date.now(),
): { token: string; expiresAt: number } | undefined {
const cached = iamTokenCache.get(region);
if (cached && cached.expiresAt > now) {
return cached;
}
return undefined;
}
/**
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
@@ -82,9 +98,9 @@ export async function generateBearerTokenFromIam(params: {
now?: () => number;
}): Promise<string | undefined> {
const now = params.now?.() ?? Date.now();
const cached = iamTokenCache.get(params.region);
const cached = getCachedIamTokenEntry(params.region, now);
if (cached && cached.expiresAt > now) {
if (cached) {
return cached.token;
}
@@ -110,6 +126,47 @@ export async function generateBearerTokenFromIam(params: {
}
}
/**
* Read a cached IAM bearer token for the given region (sync, no generation).
*
* Returns the token if it exists and has not expired, undefined otherwise.
* Used by Mantle runtime auth and tests to inspect the current cache.
*/
export function getCachedIamToken(region: string): string | undefined {
return getCachedIamTokenEntry(region)?.token;
}
export async function resolveMantleRuntimeBearerToken(params: {
apiKey: string;
env?: NodeJS.ProcessEnv;
now?: () => number;
}): Promise<{ apiKey: string; expiresAt?: number } | undefined> {
if (params.apiKey !== MANTLE_IAM_TOKEN_MARKER) {
return { apiKey: params.apiKey };
}
const now = params.now?.() ?? Date.now();
const region = resolveMantleRegion(params.env ?? process.env);
const cached = getCachedIamTokenEntry(region, now);
if (cached) {
return {
apiKey: cached.token,
expiresAt: cached.expiresAt,
};
}
const token = await generateBearerTokenFromIam({
region,
now: params.now,
});
if (!token) {
return undefined;
}
const refreshed = getCachedIamTokenEntry(region, now);
return {
apiKey: refreshed?.token ?? token,
expiresAt: refreshed?.expiresAt ?? now + IAM_TOKEN_TTL_MS,
};
}
/** Reset the IAM token cache (for testing). */
export function resetIamTokenCacheForTest(): void {
iamTokenCache.clear();
@@ -259,7 +316,7 @@ export async function resolveImplicitMantleProvider(params: {
fetchFn?: typeof fetch;
}): Promise<ModelProviderConfig | null> {
const env = params.env ?? process.env;
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
const region = resolveMantleRegion(env);
const explicitBearerToken = resolveMantleBearerToken(env);
if (!isSupportedRegion(region)) {
@@ -290,7 +347,7 @@ export async function resolveImplicitMantleProvider(params: {
baseUrl: `${mantleEndpoint(region)}/v1`,
api: "openai-completions",
auth: "api-key",
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : bearerToken,
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : MANTLE_IAM_TOKEN_MARKER,
models,
};
}

View File

@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import {
mergeImplicitMantleProvider,
resolveImplicitMantleProvider,
resolveMantleRuntimeBearerToken,
resolveMantleBearerToken,
} from "./discovery.js";
@@ -31,7 +32,12 @@ export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
},
},
resolveConfigApiKey: ({ env }) =>
resolveMantleBearerToken(env) ? "AWS_BEARER_TOKEN_BEDROCK" : undefined,
resolveMantleBearerToken(env) ? "env:AWS_BEARER_TOKEN_BEDROCK" : undefined,
prepareRuntimeAuth: async ({ apiKey, env }) =>
await resolveMantleRuntimeBearerToken({
apiKey,
env,
}),
matchesContextOverflowError: ({ errorMessage }) =>
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
classifyFailoverReason: ({ errorMessage }) => {

View File

@@ -6,6 +6,7 @@ const hoisted = vi.hoisted(() => ({
applyLocalNoAuthHeaderOverrideMock: vi.fn(),
setRuntimeApiKeyMock: vi.fn(),
resolveCopilotApiTokenMock: vi.fn(),
prepareProviderRuntimeAuthMock: vi.fn(),
}));
vi.mock("./pi-embedded-runner/model.js", () => ({
@@ -21,6 +22,10 @@ vi.mock("./github-copilot-token.js", () => ({
resolveCopilotApiToken: hoisted.resolveCopilotApiTokenMock,
}));
vi.mock("../plugins/provider-runtime.runtime.js", () => ({
prepareProviderRuntimeAuth: hoisted.prepareProviderRuntimeAuthMock,
}));
let prepareSimpleCompletionModel: typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModel;
beforeAll(async () => {
@@ -33,6 +38,7 @@ beforeEach(() => {
hoisted.applyLocalNoAuthHeaderOverrideMock.mockReset();
hoisted.setRuntimeApiKeyMock.mockReset();
hoisted.resolveCopilotApiTokenMock.mockReset();
hoisted.prepareProviderRuntimeAuthMock.mockReset();
hoisted.applyLocalNoAuthHeaderOverrideMock.mockImplementation((model: unknown) => model);
@@ -57,6 +63,7 @@ beforeEach(() => {
source: "cache:/tmp/copilot-token.json",
baseUrl: "https://api.individual.githubcopilot.com",
});
hoisted.prepareProviderRuntimeAuthMock.mockResolvedValue(undefined);
});
describe("prepareSimpleCompletionModel", () => {
@@ -340,4 +347,62 @@ describe("prepareSimpleCompletionModel", () => {
}),
);
});
it("applies provider runtime auth before storing simple-completion credentials", async () => {
hoisted.resolveModelMock.mockReturnValueOnce({
model: {
provider: "amazon-bedrock-mantle",
id: "anthropic.claude-opus-4-7",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
},
authStorage: {
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
},
modelRegistry: {},
});
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
apiKey: "__amazon_bedrock_mantle_iam__",
source: "models.providers.amazon-bedrock-mantle.apiKey",
mode: "api-key",
profileId: "mantle",
});
hoisted.prepareProviderRuntimeAuthMock.mockResolvedValueOnce({
apiKey: "bedrock-runtime-token",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
});
const result = await prepareSimpleCompletionModel({
cfg: undefined,
provider: "amazon-bedrock-mantle",
modelId: "anthropic.claude-opus-4-7",
agentDir: "/tmp/openclaw-agent",
});
expect(hoisted.prepareProviderRuntimeAuthMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "amazon-bedrock-mantle",
workspaceDir: "/tmp/openclaw-agent",
context: expect.objectContaining({
apiKey: "__amazon_bedrock_mantle_iam__",
authMode: "api-key",
modelId: "anthropic.claude-opus-4-7",
profileId: "mantle",
}),
}),
);
expect(hoisted.setRuntimeApiKeyMock).toHaveBeenCalledWith(
"amazon-bedrock-mantle",
"bedrock-runtime-token",
);
expect(result).toEqual(
expect.objectContaining({
model: expect.objectContaining({
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
}),
auth: expect.objectContaining({
apiKey: "bedrock-runtime-token",
}),
}),
);
});
});

View File

@@ -15,6 +15,7 @@ import {
resolveModelRefFromString,
} from "./model-selection.js";
import { resolveModel } from "./pi-embedded-runner/model.js";
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.runtime.js";
type SimpleCompletionAuthStorage = {
setRuntimeApiKey: (provider: string, apiKey: string) => void;
@@ -101,6 +102,10 @@ async function setRuntimeApiKeyForCompletion(params: {
authStorage: SimpleCompletionAuthStorage;
model: Model<Api>;
apiKey: string;
authMode: ResolvedProviderAuth["mode"];
cfg?: OpenClawConfig;
workspaceDir?: string;
profileId?: string;
}): Promise<CompletionRuntimeCredential> {
if (params.model.provider === "github-copilot") {
const { resolveCopilotApiToken } = await import("./github-copilot-token.js");
@@ -113,9 +118,28 @@ async function setRuntimeApiKeyForCompletion(params: {
baseUrl: copilotToken.baseUrl,
};
}
params.authStorage.setRuntimeApiKey(params.model.provider, params.apiKey);
const preparedAuth = await prepareProviderRuntimeAuth({
provider: params.model.provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
env: process.env,
context: {
config: params.cfg,
workspaceDir: params.workspaceDir,
env: process.env,
provider: params.model.provider,
modelId: params.model.id,
model: params.model,
apiKey: params.apiKey,
authMode: params.authMode,
profileId: params.profileId,
},
});
const runtimeApiKey = preparedAuth?.apiKey?.trim() || params.apiKey;
params.authStorage.setRuntimeApiKey(params.model.provider, runtimeApiKey);
return {
apiKey: params.apiKey,
apiKey: runtimeApiKey,
baseUrl: preparedAuth?.baseUrl,
};
}
@@ -177,6 +201,10 @@ export async function prepareSimpleCompletionModel(params: {
authStorage: resolved.authStorage,
model: resolved.model,
apiKey: rawApiKey,
authMode: auth.mode,
cfg: params.cfg,
workspaceDir: params.agentDir,
profileId: auth.profileId,
});
resolvedApiKey = runtimeCredential.apiKey;
const runtimeBaseUrl = runtimeCredential.baseUrl?.trim();