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"