Skip to content

Commit 5ba3505

Browse files
committed
fix(bedrock): bound mantle iam token expiry
1 parent 18e7d28 commit 5ba3505

7 files changed

Lines changed: 59 additions & 7 deletions

File tree

extensions/amazon-bedrock-mantle/discovery.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,32 @@ describe("bedrock mantle discovery", () => {
230230
expect(getCachedIamToken("us-east-1")).toBeUndefined();
231231
});
232232

233+
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
234+
const tokenProvider = vi
235+
.fn<() => Promise<string>>()
236+
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
237+
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
238+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
239+
240+
await expect(
241+
generateBearerTokenFromIam({
242+
region: "us-east-1",
243+
now: () => 8_640_000_000_000_000,
244+
tokenProviderFactory,
245+
}),
246+
).resolves.toBe("bedrock-overflow-token-1");
247+
expect(getCachedIamToken("us-east-1")).toBeUndefined();
248+
249+
await expect(
250+
generateBearerTokenFromIam({
251+
region: "us-east-1",
252+
now: () => 8_640_000_000_000_000,
253+
tokenProviderFactory,
254+
}),
255+
).resolves.toBe("bedrock-overflow-token-2");
256+
expect(tokenProvider).toHaveBeenCalledTimes(2);
257+
});
258+
233259
// ---------------------------------------------------------------------------
234260
// Model discovery
235261
// ---------------------------------------------------------------------------

extensions/amazon-bedrock-mantle/discovery.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
22
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3+
import {
4+
isFutureDateTimestampMs,
5+
resolveExpiresAtMsFromDurationMs,
6+
} from "openclaw/plugin-sdk/number-runtime";
37
import type {
48
ModelDefinitionConfig,
59
ModelProviderConfig,
@@ -92,9 +96,10 @@ function getCachedIamTokenEntry(
9296
now: number = Date.now(),
9397
): { token: string; expiresAt: number } | undefined {
9498
const cached = iamTokenCache.get(region);
95-
if (cached && cached.expiresAt > now) {
99+
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
96100
return cached;
97101
}
102+
iamTokenCache.delete(region);
98103
return undefined;
99104
}
100105

@@ -123,7 +128,10 @@ export async function generateBearerTokenFromIam(params: {
123128
region: params.region,
124129
expiresInSeconds: 7200, // 2 hours
125130
})();
126-
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
131+
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
132+
if (expiresAt !== undefined) {
133+
iamTokenCache.set(params.region, { token, expiresAt });
134+
}
127135
return token;
128136
} catch (error) {
129137
log.debug?.("Mantle IAM token generation unavailable", {

src/infra/outbound/current-conversation-bindings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { saveJsonFile } from "../../plugin-sdk/json-store.js";
88
import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-channel-state.js";
99
import {
1010
asDateTimestampMs,
11+
isFutureDateTimestampMs,
1112
resolveExpiresAtMsFromDurationMs,
1213
} from "../../shared/number-coercion.js";
1314
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
@@ -58,7 +59,7 @@ function isBindingExpired(record: SessionBindingRecord, now = Date.now()): boole
5859
return true;
5960
}
6061
const nowMs = asDateTimestampMs(now);
61-
return nowMs !== undefined && expiresAt <= nowMs;
62+
return nowMs !== undefined && !isFutureDateTimestampMs(expiresAt, { nowMs });
6263
}
6364

6465
function toPersistedFile(): PersistedCurrentConversationBindingsFile {

src/plugin-sdk/number-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
asDateTimestampMs,
55
asFiniteNumberInRange,
66
asSafeIntegerInRange,
7+
isFutureDateTimestampMs,
78
parseFiniteNumber,
89
clampTimerTimeoutMs,
910
clampPositiveTimerTimeoutMs,

src/plugin-sdk/provider-catalog-shared.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import type {
1515
ModelCatalogModel,
1616
ModelCatalogTieredCost,
1717
} from "../model-catalog/types.js";
18-
import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "../shared/number-coercion.js";
18+
import {
19+
isFutureDateTimestampMs,
20+
resolveExpiresAtMsFromDurationMs,
21+
} from "../shared/number-coercion.js";
1922
import type { ModelProviderConfig } from "./provider-model-shared.js";
2023

2124
export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js";
@@ -53,13 +56,11 @@ export async function getCachedLiveCatalogValue<T>(params: {
5356
now?: () => number;
5457
}): Promise<T> {
5558
const rawNow = params.now?.() ?? Date.now();
56-
const now = asDateTimestampMs(rawNow);
5759
const ttlMs = params.ttlMs ?? 30_000;
5860
const key = buildLiveCatalogCacheKey(params.keyParts);
5961
const existing = liveCatalogCache.get(key) as LiveCatalogCacheEntry<T> | undefined;
6062
if (existing) {
61-
const expiresAt = asDateTimestampMs(existing.expiresAt);
62-
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
63+
if (isFutureDateTimestampMs(existing.expiresAt, { nowMs: rawNow })) {
6364
return await existing.value;
6465
}
6566
liveCatalogCache.delete(key);

src/shared/number-coercion.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
clampPositiveTimerTimeoutMs,
99
clampTimerTimeoutMs,
1010
finiteSecondsToTimerSafeMilliseconds,
11+
isFutureDateTimestampMs,
1112
MAX_TIMER_TIMEOUT_MS,
1213
MAX_TIMER_TIMEOUT_SECONDS,
1314
nonNegativeSecondsToSafeMilliseconds,
@@ -136,6 +137,14 @@ describe("number-coercion", () => {
136137
expect(timestampMsToIsoString("0")).toBeUndefined();
137138
});
138139

140+
test("future timestamp helper rejects invalid Date timestamps", () => {
141+
expect(isFutureDateTimestampMs(1_001, { nowMs: 1_000 })).toBe(true);
142+
expect(isFutureDateTimestampMs(1_000, { nowMs: 1_000 })).toBe(false);
143+
expect(isFutureDateTimestampMs(999, { nowMs: 1_000 })).toBe(false);
144+
expect(isFutureDateTimestampMs(8_640_000_000_000_001, { nowMs: 1_000 })).toBe(false);
145+
expect(isFutureDateTimestampMs(1_001, { nowMs: Number.NaN })).toBe(false);
146+
});
147+
139148
test("timestamp fallback helpers resolve Date-invalid timestamps", () => {
140149
expect(resolveDateTimestampMs(1_000)).toBe(1_000);
141150
expect(resolveDateTimestampMs(Number.POSITIVE_INFINITY, 1_000)).toBe(1_000);

src/shared/number-coercion.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export function asDateTimestampMs(value: unknown): number | undefined {
105105
});
106106
}
107107

108+
export function isFutureDateTimestampMs(value: unknown, opts: { nowMs?: number } = {}): boolean {
109+
const timestampMs = asDateTimestampMs(value);
110+
const nowMs = asDateTimestampMs(opts.nowMs ?? Date.now());
111+
return timestampMs !== undefined && nowMs !== undefined && timestampMs > nowMs;
112+
}
113+
108114
export function timestampMsToIsoString(value: unknown): string | undefined {
109115
const timestampMs = asDateTimestampMs(value);
110116
return timestampMs === undefined ? undefined : new Date(timestampMs).toISOString();

0 commit comments

Comments
 (0)