fix(kiro): probe the IdC region too during profileArn discovery (any IdC region)

Make profile discovery general for an IdC in ANY of the ~30 IdC-supported AWS regions
(us-west-2, ap-southeast-2, me-central-1, af-south-1, ...), not just eu-north-1.

buildKiroProfileDiscoveryRegions now probes the two documented Q Developer profile regions
FIRST (us-east-1 / eu-central-1, EU-first for EMEA IdC regions to cut latency), then appends
the IdC/stored region itself as a forward-compatible fallback: if AWS ever co-locates the
profile with the IdC or expands the profile-region list, a same-region probe still finds it.
Probing a region with no profile simply returns nothing and we fall through. The profileArn's
own region remains authoritative for every runtime call (resolveKiroRuntimeRegion), so a
newly-issued ARN in any region is honored automatically.

Adds ap-southeast-2 (APAC) cross-region coverage and updates the discovery-order tests.
This commit is contained in:
artickc
2026-07-03 16:38:35 +03:00
committed by Diego Rodrigues de Sa e Souza
parent f2370991ec
commit f8c2f23ae6
2 changed files with 97 additions and 47 deletions

View File

@@ -1,28 +1,31 @@
/**
* Shared Amazon Q Developer (Kiro / AWS CodeWhisperer) region resolution.
*
* CRITICAL AWS constraint — verified against the AWS docs
* "Amazon Q Developer Pro Region support" → "Supported Regions for the Q Developer console and
* Q Developer profile":
* TWO DISTINCT REGIONS — verified against the AWS docs "Amazon Q Developer Pro Region support"
* ("Supported Regions for the Q Developer console and Q Developer profile"):
*
* • An IAM Identity Center instance (and therefore the SSO OIDC token endpoint
* `oidc.{region}.amazonaws.com`) may live in MANY regions, e.g. eu-north-1 (Stockholm),
* us-west-1, ap-southeast-2, ...
* • The Amazon Q Developer PROFILE — which produces the `profileArn` and hosts EVERY
* CodeWhisperer runtime call (generateAssistantResponse, GetUsageLimits, ListAvailableModels,
* ListAvailableProfiles) — is only hosted in **us-east-1** and **eu-central-1**.
* • "Regardless of the IAM Identity Center Region, data is stored in the Region where you create
* the Amazon Q Developer profile." → the IdC region and the runtime region are frequently
* DIFFERENT (e.g. IdC in eu-north-1 → profile in eu-central-1).
* • IdC / OIDC / token region — `providerSpecificData.region`. May be ANY of the ~30 IdC-
* supported AWS regions (us-east-1, us-west-2, ca-central-1, sa-east-1, eu-west-1/2/3,
* eu-central-1/2, eu-north-1, eu-south-1/2, ap-south-1/2, ap-east-1/2, ap-southeast-1..7,
* ap-northeast-1/2/3, me-central-1, me-south-1, af-south-1, il-central-1, …). Used ONLY for
* `oidc.{region}.amazonaws.com` token mint/refresh (see tokenRefresh.ts / oauth providers).
* • Q Developer PROFILE / RUNTIME region — where the `profileArn` lives and every CodeWhisperer
* runtime call is served (generateAssistantResponse, GetUsageLimits, ListAvailableModels,
* ListAvailableProfiles). AWS currently hosts the profile ONLY in us-east-1 and eu-central-1,
* REGARDLESS of the IdC region ("Regardless of the IAM Identity Center Region, data is stored
* in the Region where you create the Amazon Q Developer profile"). The AWS docs' own example:
* an IdC in us-west-1 → profile in us-east-1.
*
* Consequences enforced here:
* • `providerSpecificData.region` is the IdC/OIDC/token region. It must ONLY be used for
* `oidc.{region}.amazonaws.com` token mint/refresh (see tokenRefresh.ts / oauth providers).
* • The RUNTIME region is the region embedded in the `profileArn` (us-east-1 / eu-central-1),
* NOT the IdC region. Routing a runtime call to `q.eu-north-1.amazonaws.com` — a host that
* does not exist as a Q Developer runtime endpoint — is the root cause of the
* "Kiro IAM shows no limits + every request returns 502" failure for enterprise IdC accounts
* whose IdC lives outside us-east-1 / eu-central-1.
* • The RUNTIME region is the region embedded in the `profileArn` (authoritative — whatever
* region AWS actually hosts the profile in), NOT the IdC region. Routing a runtime call to
* `q.{idcRegion}.amazonaws.com` for a non-profile IdC region (e.g. q.eu-north-1, which does
* not exist as a Q Developer runtime endpoint) is the root cause of the "Kiro IAM shows no
* limits + every request returns 502" failure.
* • profileArn discovery works for an IdC in ANY region: it probes the known profile regions
* (us-east-1 / eu-central-1) with the cross-region SSO token, AND the IdC's own region as a
* forward-compatible fallback (in case AWS ever co-locates or expands profile regions). The
* discovered ARN's region then drives every runtime call.
*/
// Canonical AWS region shape — kept local (identical to AWS_REGION_PATTERN in
@@ -32,9 +35,11 @@
export const AWS_REGION_PATTERN = /^[a-z]{2}-[a-z]+-\d{1,2}$/;
/**
* Regions where the Amazon Q Developer *profile* (identity-aware / IdC Pro tier) can exist, and
* therefore the only regions that host a Q Developer runtime endpoint for profile-bound calls.
* Order is the default probe order (us-east-1 is CodeWhisperer's home region).
* Regions where the Amazon Q Developer *profile* is currently hosted (AWS docs: "Supported
* Regions for the Q Developer console and Q Developer profile"). These are the guaranteed
* discovery targets and the only regions trusted as a runtime fallback when no profileArn is
* known. The profileArn's own region is always honored above this list, so a future AWS
* profile-region expansion works automatically once an ARN is discovered.
*/
export const KIRO_PROFILE_REGIONS = ["us-east-1", "eu-central-1"] as const;
@@ -88,25 +93,27 @@ export function resolveKiroRuntimeRegion(
}
/**
* Build the ordered list of Q Developer profile regions to probe for `ListAvailableProfiles`.
* The IdC/token region is only useful here as a hint for geographic proximity — the actual
* profile always lives in one of KIRO_PROFILE_REGIONS, so those are the only regions probed.
* Build the ordered list of regions to probe for `ListAvailableProfiles`.
*
* The Amazon Q Developer profile (and thus every runtime endpoint) is currently hosted only in
* KIRO_PROFILE_REGIONS (us-east-1 / eu-central-1) regardless of the IdC region, so those are
* probed FIRST — EU-first when the IdC region is in EMEA (eu-, af-, me-, il- prefixes) to
* minimize latency. The IdC/stored region is then appended as a forward-compatible fallback: if
* AWS ever co-locates the profile with the IdC, or expands the profile-region list, a same-region
* probe still finds it. It is only appended when it is a valid AWS region distinct from the known
* profile regions; probing a region with no profile simply returns nothing and we fall through.
* This makes discovery work for an IdC in ANY region (us-west-2, ap-southeast-2, me-central-1,
* af-south-1, …), not just eu-north-1.
*/
export function buildKiroProfileDiscoveryRegions(storedRegion?: string | null): string[] {
const regions: string[] = [];
const stored = normalizeRegion(storedRegion);
// If the IdC/token region happens to be a Q profile region itself, probe it first.
if (stored && (KIRO_PROFILE_REGIONS as readonly string[]).includes(stored)) {
regions.push(stored);
}
// Otherwise order the two known profile regions by rough geographic proximity to the IdC
// region so an EU IdC (e.g. eu-north-1) hits eu-central-1 first.
const preferEu = /^(eu|af|me|il)-/.test(stored);
const ordered = preferEu ? ["eu-central-1", "us-east-1"] : ["us-east-1", "eu-central-1"];
for (const r of ordered) {
if (!regions.includes(r)) regions.push(r);
const regions: string[] = preferEu
? ["eu-central-1", "us-east-1"]
: ["us-east-1", "eu-central-1"];
if (stored && AWS_REGION_PATTERN.test(stored) && !regions.includes(stored)) {
regions.push(stored);
}
return regions;
}
@@ -151,11 +158,12 @@ async function listKiroProfileArnForRegion(
/**
* Discover a Kiro/CodeWhisperer profile ARN by probing the Q Developer profile regions
* (us-east-1 / eu-central-1) with the account's access token. The SSO bearer token minted from
* the IdC region works cross-region against the Q Developer profile's region (AWS's documented
* multi-region IdC ⇄ profile setup). Returns the first ARN found (its embedded region is the
* authoritative runtime region), or undefined when no profile is available (e.g. AWS Builder ID
* accounts, or an org/token with no Kiro entitlement). Best-effort: never throws.
* (us-east-1 / eu-central-1) AND the IdC/stored region with the account's access token. The SSO
* bearer token minted from the IdC region works cross-region against the Q Developer profile's
* region (AWS's documented multi-region IdC ⇄ profile setup), so an IdC in ANY region resolves.
* Returns the first ARN found (its embedded region is the authoritative runtime region), or
* undefined when no profile is available (e.g. AWS Builder ID accounts, or an org/token with no
* Kiro entitlement). Best-effort: never throws.
*/
export async function discoverKiroProfileArnAcrossRegions(
accessToken: string | null | undefined,

View File

@@ -79,14 +79,32 @@ test("the executor's resolveKiroRegion routes an eu-north-1 IdC account to the p
assert.equal(kiroRuntimeHost("eu-central-1"), "https://q.eu-central-1.amazonaws.com");
});
test("buildKiroProfileDiscoveryRegions: EU IdC probes eu-central-1 first, never eu-north-1", () => {
test("buildKiroProfileDiscoveryRegions: EU IdC probes the profile regions first, then the IdC region", () => {
const regions = buildKiroProfileDiscoveryRegions("eu-north-1");
assert.deepEqual(regions, ["eu-central-1", "us-east-1"]);
assert.ok(!regions.includes("eu-north-1"), "must not probe the nonexistent q.eu-north-1 host");
assert.deepEqual(regions, ["eu-central-1", "us-east-1", "eu-north-1"]);
// The profile regions (fast path) are tried BEFORE the IdC-region fallback.
assert.ok(regions.indexOf("eu-central-1") < regions.indexOf("eu-north-1"));
assert.ok(regions.indexOf("us-east-1") < regions.indexOf("eu-north-1"));
// Another EMEA IdC region → still EU-first, IdC region appended as fallback.
assert.deepEqual(buildKiroProfileDiscoveryRegions("me-central-1"), [
"eu-central-1",
"us-east-1",
"me-central-1",
]);
});
test("buildKiroProfileDiscoveryRegions: non-EU IdC probes us-east-1 first", () => {
assert.deepEqual(buildKiroProfileDiscoveryRegions("us-west-2"), ["us-east-1", "eu-central-1"]);
test("buildKiroProfileDiscoveryRegions: non-EU IdC probes us-east-1 first, then the IdC region", () => {
assert.deepEqual(buildKiroProfileDiscoveryRegions("us-west-2"), [
"us-east-1",
"eu-central-1",
"us-west-2",
]);
assert.deepEqual(buildKiroProfileDiscoveryRegions("ap-southeast-2"), [
"us-east-1",
"eu-central-1",
"ap-southeast-2",
]);
// No stored region → just the two profile regions.
assert.deepEqual(buildKiroProfileDiscoveryRegions(undefined), ["us-east-1", "eu-central-1"]);
});
@@ -121,6 +139,30 @@ test("discoverKiroProfileArnAcrossRegions: eu-north-1 IdC finds the eu-central-1
);
});
test("discoverKiroProfileArnAcrossRegions: a non-EU (ap-southeast-2) IdC resolves a us-east-1 profile", async () => {
// Proves the fix is general, not eu-north-1-specific: an APAC IdC's profile lives in a Q
// profile region (us-east-1 here) and is found via the cross-region SSO token.
const US_EAST_ARN = "arn:aws:codewhisperer:us-east-1:111111111111:profile/APAC";
const requested: string[] = [];
const fetchImpl = (async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url.includes("us-east-1")) {
return new Response(JSON.stringify({ profiles: [{ arn: US_EAST_ARN }] }), { status: 200 });
}
return new Response(JSON.stringify({ profiles: [] }), { status: 200 });
}) as unknown as typeof fetch;
const arn = await discoverKiroProfileArnAcrossRegions("sso-token", "ap-southeast-2", fetchImpl);
assert.equal(arn, US_EAST_ARN);
assert.equal(
resolveKiroRuntimeRegion({ region: "ap-southeast-2", profileArn: arn }),
"us-east-1"
);
// The us-east-1 profile region is probed before the ap-southeast-2 IdC-region fallback.
assert.ok(requested.some((u) => u.startsWith("https://codewhisperer.us-east-1.amazonaws.com/")));
});
test("discoverKiroProfileArnAcrossRegions: no token / no profile yields undefined without throwing", async () => {
assert.equal(await discoverKiroProfileArnAcrossRegions("", "eu-north-1"), undefined);
const emptyFetch = (async () =>