From 40102f74c0eca674ef785a017736a78c70f53a69 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 20:37:00 +0800 Subject: [PATCH] =?UTF-8?q?kiro:=20a=20Builder=20ID=20sign-in=20is=20asked?= =?UTF-8?q?=20with=20Builder=20ID's=20service=20profile,=20so=20its=20mode?= =?UTF-8?q?ls=20are=20listed=20(#422,=20Q1CHENL:=20only=20Auto=20offered?= =?UTF-8?q?=20while=20kiro-cli=20lists=20Opus=205.5,=20Sonnet=205=20?= =?UTF-8?q?=E2=80=A6)=20=E2=80=94=20a=20Builder=20ID=20sign-in=20(kiro-cli?= =?UTF-8?q?'s,=20the=20Kiro=20IDE's=20or=20magpie's=20own)=20names=20no=20?= =?UTF-8?q?profile,=20and=20Kiro=20won't=20list=20one=20for=20it:=20List-A?= =?UTF-8?q?vailable-Profiles=20answers=20403=20"AWS=20Builder=20ID=20is=20?= =?UTF-8?q?not=20supported=20for=20this=20operation"=20in=20both=20regions?= =?UTF-8?q?,=20so=20every=20call=20needing=20the=20account's=20auth=20fail?= =?UTF-8?q?ed=20=E2=80=94=20List-Available-Models=20(magpie=20fell=20back?= =?UTF-8?q?=20to=20Auto=20alone),=20Get-Usage-Limits=20(the=20account=20st?= =?UTF-8?q?ayed=20"Kiro=20account")=20and=20chat.=20A=20sign-in=20kiro-cli?= =?UTF-8?q?=20keeps=20with=20Builder=20ID's=20start=20URL=20(or=20none,=20?= =?UTF-8?q?as=20kiro-cli=20takes=20it),=20or=20one=20saved=20with=20provid?= =?UTF-8?q?er=20BuilderId,=20now=20takes=20the=20service=20profile=20the?= =?UTF-8?q?=20Kiro=20IDE=20and=20kiro-cli=20use=20for=20Builder=20ID=20(ar?= =?UTF-8?q?n:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX?= =?UTF-8?q?)=20without=20asking,=20and=20an=20AWS=20sign-in=20whose=20prof?= =?UTF-8?q?ile=20listing=20is=20that=20403=20takes=20it=20too.=20Identity?= =?UTF-8?q?=20Center=20sign-ins=20still=20have=20their=20profile=20listed.?= =?UTF-8?q?=20TestKiroBuilderIDListsItsModels=20and=20TestKiroBuilderIDFou?= =?UTF-8?q?ndByKirosAnswer=20fail=20without=20the=20change=20(fake=20manag?= =?UTF-8?q?ement=20API).=20go=20vet,=20go=20build=20-tags=20nogui=20(darwi?= =?UTF-8?q?n,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20build,=20go=20test?= =?UTF-8?q?=20-tags=20nogui=20./...=20pass=20(TestFXInState's=20temp-dir?= =?UTF-8?q?=20cleanup=20flaked=20once=20under=20load,=20passes=20alone=20a?= =?UTF-8?q?nd=20with=20its=20package).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/kiro.go | 35 ++++++- internal/provider/kiro_builderid_test.go | 122 +++++++++++++++++++++++ internal/provider/kiro_signin.go | 2 +- 3 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 internal/provider/kiro_builderid_test.go diff --git a/internal/provider/kiro.go b/internal/provider/kiro.go index 8daccefc..e678f9e0 100644 --- a/internal/provider/kiro.go +++ b/internal/provider/kiro.go @@ -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 } diff --git a/internal/provider/kiro_builderid_test.go b/internal/provider/kiro_builderid_test.go new file mode 100644 index 00000000..02bcf923 --- /dev/null +++ b/internal/provider/kiro_builderid_test.go @@ -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) + } +} diff --git a/internal/provider/kiro_signin.go b/internal/provider/kiro_signin.go index 92d02290..18d3f1b4 100644 --- a/internal/provider/kiro_signin.go +++ b/internal/provider/kiro_signin.go @@ -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"