diff --git a/CHANGELOG.md b/CHANGELOG.md index fc59726f133..3b7c4ba9e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/amazon-bedrock-mantle/api.ts b/extensions/amazon-bedrock-mantle/api.ts index d235d355eff..1a262e411bf 100644 --- a/extensions/amazon-bedrock-mantle/api.ts +++ b/extensions/amazon-bedrock-mantle/api.ts @@ -1,9 +1,12 @@ export { discoverMantleModels, generateBearerTokenFromIam, + getCachedIamToken, + MANTLE_IAM_TOKEN_MARKER, mergeImplicitMantleProvider, resetIamTokenCacheForTest, resetMantleDiscoveryCacheForTest, resolveImplicitMantleProvider, resolveMantleBearerToken, + resolveMantleRuntimeBearerToken, } from "./discovery.js"; diff --git a/extensions/amazon-bedrock-mantle/discovery.test.ts b/extensions/amazon-bedrock-mantle/discovery.test.ts index c24e085e1b6..544c86339e2 100644 --- a/extensions/amazon-bedrock-mantle/discovery.test.ts +++ b/extensions/amazon-bedrock-mantle/discovery.test.ts @@ -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: { diff --git a/extensions/amazon-bedrock-mantle/discovery.ts b/extensions/amazon-bedrock-mantle/discovery.ts index 8d465e5079f..62151e2326a 100644 --- a/extensions/amazon-bedrock-mantle/discovery.ts +++ b/extensions/amazon-bedrock-mantle/discovery.ts @@ -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(); -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 { 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 { 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, }; } diff --git a/extensions/amazon-bedrock-mantle/register.sync.runtime.ts b/extensions/amazon-bedrock-mantle/register.sync.runtime.ts index ff04c6c4718..b98ab4e5cd5 100644 --- a/extensions/amazon-bedrock-mantle/register.sync.runtime.ts +++ b/extensions/amazon-bedrock-mantle/register.sync.runtime.ts @@ -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 }) => { diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts index 572f43c9547..925d3be6428 100644 --- a/src/agents/simple-completion-runtime.test.ts +++ b/src/agents/simple-completion-runtime.test.ts @@ -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", + }), + }), + ); + }); }); diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 050a52b9f9c..86060849cf0 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -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; apiKey: string; + authMode: ResolvedProviderAuth["mode"]; + cfg?: OpenClawConfig; + workspaceDir?: string; + profileId?: string; }): Promise { 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();