mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
getCachedIamToken,
|
||||
MANTLE_IAM_TOKEN_MARKER,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
resolveMantleRuntimeBearerToken,
|
||||
} from "./discovery.js";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user