kiro: a Builder ID sign-in is asked with Builder ID's service profile, so its models are listed (#422, Q1CHENL: only Auto offered while kiro-cli lists Opus 5.5, Sonnet 5 …) — a Builder ID sign-in (kiro-cli's, the Kiro IDE's or magpie's own) names no profile, and Kiro won't list one for it: List-Available-Profiles answers 403 "AWS Builder ID is not supported for this operation" in both regions, so every call needing the account's auth failed — List-Available-Models (magpie fell back to Auto alone), Get-Usage-Limits (the account stayed "Kiro account") and chat. A sign-in kiro-cli keeps with Builder ID's start URL (or none, as kiro-cli takes it), or one saved with provider BuilderId, now takes the service profile the Kiro IDE and kiro-cli use for Builder ID (arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX) without asking, and an AWS sign-in whose profile listing is that 403 takes it too. Identity Center sign-ins still have their profile listed. TestKiroBuilderIDListsItsModels and TestKiroBuilderIDFoundByKirosAnswer fail without the change (fake management API). go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestFXInState's temp-dir cleanup flaked once under load, passes alone and with its package).

This commit is contained in:
yetone committed 2026-10-01 20:45:11 +08:00
1 parent 986a4474fe
commit 40102f74c0
3 files changed
+154 -5

No files matched your search

+31 -4
View File
@@ -97,6 +97,7 @@ type kiroCred struct {
clientSecret string // idc
tokenURL string // external-idp
social bool // an IDE sign-in with Google or GitHub
builderID bool // an AWS Builder ID sign-in, which has no profile of its own
dbKey string // the auth_kv row it came from
idePath string // or the IDE's file
@@ -145,6 +146,8 @@ func readKiroCLI() (kiroCred, bool) {
c.method = "social"
case "odic":
c.method = "idc"
// as kiro-cli tells them apart: Builder ID's start URL, or none
c.builderID = str(m, "start_url") == "" || str(m, "start_url") == kiroBuilderIDStart
if reg := get("kirocli:odic:device-registration"); reg != nil {
c.clientID, c.clientSecret = str(reg, "client_id"), str(reg, "client_secret")
}
@@ -177,12 +180,14 @@ func readKiroIDE() (kiroCred, bool) {
Region string `json:"region"`
ClientIDHash string `json:"clientIdHash"`
AuthMethod string `json:"authMethod"`
Provider string `json:"provider"`
ProfileArn string `json:"profileArn"`
}
if json.Unmarshal(b, &t) != nil || t.AccessToken == "" {
return kiroCred{}, false
}
c := kiroCred{access: t.AccessToken, refresh: t.RefreshToken, region: t.Region, profile: t.ProfileArn, method: "idc", idePath: path}
c := kiroCred{access: t.AccessToken, refresh: t.RefreshToken, region: t.Region, profile: t.ProfileArn, method: "idc", idePath: path,
builderID: strings.EqualFold(t.Provider, "BuilderId")}
c.expires, _ = time.Parse(time.RFC3339Nano, t.ExpiresAt)
if c.region == "" {
c.region = "us-east-1"
@@ -494,10 +499,14 @@ func kiroPost(ctx context.Context, u, contentType string, body []byte, headers m
return json.Unmarshal(b, dst)
}
// kiroManagementURL is where Kiro's management API is in a region; a var so
// tests can answer it.
var kiroManagementURL = func(region string) string { return "https://management." + region + ".kiro.dev/" }
// kiroManagement calls Kiro's management API: GET with a query, or POST
// with a JSON body when body is set.
func kiroManagement(ctx context.Context, region, token, tokenType, method string, q url.Values, body any, dst any) error {
u := "https://management." + region + ".kiro.dev/" + method
u := kiroManagementURL(region) + method
verb := http.MethodGet
var rd io.Reader
if body != nil {
@@ -543,11 +552,21 @@ func (e *kiroStatusError) Error() string {
return "Kiro: " + http.StatusText(e.status)
}
// A Builder ID sign-in has no profile of its own, and Kiro won't list one
// for it (List-Available-Profiles is a 403, "AWS Builder ID is not supported
// for this operation"); the Kiro IDE and kiro-cli name this service profile
// for it instead, so its models, usage and chat are asked with it too.
const (
kiroBuilderIDStart = "https://view.awsapps.com/start"
kiroBuilderIDProfile = "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX"
)
// kiroProfile finds the profile a sign-in that doesn't name one uses: an
// API key's own, or the first Kiro lists in either of its regions.
// API key's own, Builder ID's, or the first Kiro lists in either of its
// regions.
func kiroProfile(ctx context.Context, c kiroCred) (string, error) {
if c.method == "apikey" {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://management.us-east-1.kiro.dev/", strings.NewReader("{}"))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kiroManagementURL("us-east-1"), strings.NewReader("{}"))
if err != nil {
return "", err
}
@@ -573,6 +592,9 @@ func kiroProfile(ctx context.Context, c kiroCred) (string, error) {
}
return out.Profile.Arn, nil
}
if c.builderID {
return kiroBuilderIDProfile, nil
}
tt := ""
if c.method == "external-idp" {
tt = "EXTERNAL_IDP"
@@ -585,6 +607,11 @@ func kiroProfile(ctx context.Context, c kiroCred) (string, error) {
} `json:"profiles"`
}
if err := kiroManagement(ctx, region, c.access, tt, "List-Available-Profiles", nil, map[string]any{}, &out); err != nil {
// a Builder ID sign-in the token didn't say was one
var se *kiroStatusError
if c.method == "idc" && errors.As(err, &se) && se.status == http.StatusForbidden && strings.Contains(se.body, "Builder ID") {
return kiroBuilderIDProfile, nil
}
last = err
continue
}
+122
View File
@@ -0,0 +1,122 @@
package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fakeKiroManagement answers Kiro's management API as it answers a Builder
// ID sign-in: no profiles to list (a 403), and the account's models and
// usage for the Builder ID service profile.
func fakeKiroManagement(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
asked := []string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
asked = append(asked, r.URL.Path)
mu.Unlock()
switch r.URL.Path {
case "/List-Available-Profiles":
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message":"AWS Builder ID is not supported for this operation."}`))
case "/List-Available-Models":
if r.URL.Query().Get("profileArn") != kiroBuilderIDProfile {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"message":"profileArn is required"}`))
return
}
json.NewEncoder(w).Encode(map[string]any{"defaultModel": map[string]any{"modelId": "auto"}, "models": []any{
map[string]any{"modelId": "auto", "modelName": "auto", "tokenLimits": map[string]any{"maxInputTokens": 1000000}},
map[string]any{"modelId": "claude-opus-5.5", "modelName": "Claude Opus 5.5", "supportedInputTypes": []string{"TEXT", "IMAGE"},
"tokenLimits": map[string]any{"maxInputTokens": 1000000, "maxOutputTokens": 128000}},
map[string]any{"modelId": "claude-sonnet-5", "modelName": "Claude Sonnet 5", "tokenLimits": map[string]any{"maxInputTokens": 1000000}},
map[string]any{"modelId": "claude-haiku-4.5", "modelName": "Claude Haiku 4.5", "tokenLimits": map[string]any{"maxInputTokens": 200000}},
}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
was := kiroManagementURL
kiroManagementURL = func(string) string { return srv.URL + "/" }
t.Cleanup(func() { kiroManagementURL = was })
return &asked
}
func kiroFetchedIDs(t *testing.T) []string {
t.Helper()
p, ok := kiroAccount()
if !ok {
t.Fatal("no Kiro account")
}
ms, err := p.Fetch(context.Background())
if err != nil {
t.Fatalf("fetch: %v", err)
}
var ids []string
for _, m := range ms {
ids = append(ids, m.ID)
}
return ids
}
// A Builder ID sign-in (kiro-cli's here) names no profile and Kiro lists
// none for it, so magpie couldn't ask for its models at all and offered
// Auto alone (#422); it is asked with Builder ID's service profile, as
// kiro-cli and the IDE do, and Kiro's whole list is offered.
func TestKiroBuilderIDListsItsModels(t *testing.T) {
kiroSandbox(t)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
asked := fakeKiroManagement(t)
writeKiroCLI(t, map[string]any{
"kirocli:odic:token": map[string]any{"access_token": "at", "refresh_token": "rt", "expires_at": "2099-01-01T00:00:00Z",
"region": "us-east-1", "start_url": kiroBuilderIDStart, "oauth_flow": "DeviceCode"},
"kirocli:odic:device-registration": map[string]any{"client_id": "cid", "client_secret": "sec"},
})
got := strings.Join(kiroFetchedIDs(t), ",")
if got != "auto,claude-opus-5.5,claude-sonnet-5,claude-haiku-4.5" {
t.Fatalf("models = %s (asked %v)", got, *asked)
}
for _, a := range *asked {
if a == "/List-Available-Profiles" {
t.Fatalf("asked for a Builder ID sign-in's profiles: %v", *asked)
}
}
a, err := KiroAuthOf(context.Background(), "", "", false)
if err != nil || a.Profile != kiroBuilderIDProfile || a.Region != "us-east-1" {
t.Fatalf("auth = %+v %v", a, err)
}
// once listed, the account offers them, not Auto alone
p, _ := kiroAccount()
if ms := p.Available(); len(ms) != 4 {
t.Fatalf("available = %+v", ms)
}
}
// An Identity Center sign-in still has its profile listed; one that turns
// out to be Builder ID's (the IDE's file not saying) takes Builder ID's
// profile when Kiro says it has none to list.
func TestKiroBuilderIDFoundByKirosAnswer(t *testing.T) {
kiroSandbox(t)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
asked := fakeKiroManagement(t)
dir := kiroIDEDir()
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "kiro-auth-token.json"), []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":"2099-01-01T00:00:00Z",
"authMethod":"IdC","region":"us-east-1","clientIdHash":"h"}`), 0o600)
os.WriteFile(filepath.Join(dir, "h.json"), []byte(`{"clientId":"cid","clientSecret":"sec"}`), 0o600)
if got := strings.Join(kiroFetchedIDs(t), ","); got != "auto,claude-opus-5.5,claude-sonnet-5,claude-haiku-4.5" {
t.Fatalf("models = %s (asked %v)", got, *asked)
}
if (*asked)[0] != "/List-Available-Profiles" {
t.Fatalf("asked %v", *asked)
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ func readKiroFile(path string) (kiroCred, bool) {
return kiroCred{}, false
}
c := kiroCred{access: t.AccessToken, refresh: t.RefreshToken, region: t.Region, profile: t.ProfileArn,
clientID: t.ClientID, clientSecret: t.ClientSecret, idePath: path}
clientID: t.ClientID, clientSecret: t.ClientSecret, idePath: path, builderID: strings.EqualFold(t.Provider, "BuilderId")}
c.expires, _ = time.Parse(time.RFC3339Nano, t.ExpiresAt)
if c.region == "" {
c.region = "us-east-1"