From 47d2c0eb7a6a661555824d4c08b2f3698247c1a1 Mon Sep 17 00:00:00 2001 From: Bryn Price Date: Fri, 12 Jun 2026 15:49:10 +0900 Subject: [PATCH] feat(auth): add Claude Platform on AWS credential tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new highest-priority, opt-in credential tier that routes requests through the Claude Platform on AWS gateway (which does not support OAuth), unblocking the version-controlled Managed Agents workflow on AWS. Selected only by explicit opt-in — the --aws flag or the persistent ANTHROPIC_USE_AWS toggle (not ambient AWS env vars). When active, getDefaultRequestOptions short-circuits before the first-party credential switch and builds request options from the SDK's aws/ backend (SigV4 via the default AWS credential chain, or x-api-key when --aws-api-key is set). All ~40 Stainless-generated commands work unchanged over the AWS transport. Changes are confined to hand-written files: - extras.go: register --aws, --aws-region, --aws-workspace-id, --aws-api-key global flags (each paired with its env Source). - cmdutil.go: buildAWSConfig pure helper, the AWS tier-0 short-circuit, and warnIfAWSConflict (warns when --aws overrides a first-party cred; excludes --aws-api-key, which only selects API-key mode). - cmd_auth.go: awsAuthStatus renders AWS as the tier-0 winner with the active mode label and backend-resolved base URL, redacting the API key. - cmd_aws_test.go: buildAWSConfig pass-through, conflict warning fires/silent, auth status row, and the cross-site precedence invariant. The anthropic-sdk-go pin (v1.50.1) is unchanged — its aws/ subpackage was already present; go.mod only records the AWS SDK transitive indirect deps it newly makes reachable. --- go.mod | 13 ++ go.sum | 26 ++++ pkg/cmd/cmd_auth.go | 72 ++++++++- pkg/cmd/cmd_aws_test.go | 319 ++++++++++++++++++++++++++++++++++++++++ pkg/cmd/cmdutil.go | 104 ++++++++++++- pkg/cmd/extras.go | 25 ++++ 6 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/cmd_aws_test.go diff --git a/go.mod b/go.mod index 5eec99c..acbd09b 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,19 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect + github.com/aws/smithy-go v1.20.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect diff --git a/go.sum b/go.sum index 17cbb68..808a058 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,31 @@ github.com/anthropics/anthropic-sdk-go v1.50.1 h1:XTd1RkdeHCPusPpzcBY5RIWj/WW6ZktjftxrHvQBJfU= github.com/anthropics/anthropic-sdk-go v1.50.1/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= +github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90= +github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= diff --git a/pkg/cmd/cmd_auth.go b/pkg/cmd/cmd_auth.go index 04ba861..189575f 100644 --- a/pkg/cmd/cmd_auth.go +++ b/pkg/cmd/cmd_auth.go @@ -619,10 +619,22 @@ func readCredentials(cfg *config.Config, dir, profile string) (storedCredentials } func authStatus(ctx context.Context, c *cli.Command) error { + out := os.Stdout + + // Tier 0 — Claude Platform on AWS. When --aws / ANTHROPIC_USE_AWS is active + // the request short-circuits to the AWS gateway in getDefaultRequestOptions, + // so the profile/key/federation rows below would mislabel a non-winner as + // the winner. Render a focused AWS status and return. This matters in the + // default CI case: with ANTHROPIC_USE_AWS persistent in the env, Bool("aws") + // is true even without typing --aws, and without this the report would claim + // "(no credential configured…)" while requests in the same env succeed. + if c.Root().Bool("aws") { + return awsAuthStatus(out, c.Root()) + } + dir := config.DefaultDir() profile, profileSource := activeProfileWithSource(c, dir) - out := os.Stdout fmt.Fprintf(out, "Active profile: %s (%s)\n", profile, profileSource) fmt.Fprintf(out, "Config dir: %s\n", dir) fmt.Fprintf(out, "Profile config: %s\n", config.ProfilePath(dir, profile)) @@ -895,6 +907,64 @@ func authStatus(ctx context.Context, c *cli.Command) error { return nil } +// awsAuthStatus renders `auth status` when the Claude Platform on AWS tier is +// active (--aws / ANTHROPIC_USE_AWS). It shows AWS as the tier-0 winner and the +// values the backend will use, mirroring buildAWSConfig / the SDK's resolution +// (region: --aws-region > AWS_REGION > AWS_DEFAULT_REGION via the flag's +// Sources; base URL: backend-resolved from region when not overridden). No +// profile/key/federation rows are printed — they don't apply on this transport. +func awsAuthStatus(out io.Writer, root *cli.Command) error { + fmt.Fprintln(out, "Backend: Claude Platform on AWS (--aws / ANTHROPIC_USE_AWS)") + + fmt.Fprintln(out) + fmt.Fprintln(out, "Credentials") + apiKey := root.String("aws-api-key") + if apiKey != "" { + writeRow(out, true, "AWS gateway (API key, x-api-key)", formatSecret(apiKey, true)) + writeDetail(out, "source", "--aws-api-key / ANTHROPIC_AWS_API_KEY") + } else { + writeRow(out, true, "AWS gateway (SigV4)", "via AWS credential chain") + writeDetail(out, "source", "default AWS credential chain (IAM role / AWS_PROFILE / env)") + } + + region := root.String("aws-region") + workspaceID := root.String("aws-workspace-id") + + fmt.Fprintln(out) + fmt.Fprintln(out, "Region") + if region != "" { + writeRow(out, true, "--aws-region / AWS_REGION / AWS_DEFAULT_REGION", region) + } else { + writeRow(out, true, "(unset)", "set --aws-region or AWS_REGION (required)") + } + + fmt.Fprintln(out) + fmt.Fprintln(out, "Workspace") + if workspaceID != "" { + writeRow(out, true, "--aws-workspace-id / ANTHROPIC_AWS_WORKSPACE_ID", workspaceID) + } else { + writeRow(out, true, "(unset)", "set --aws-workspace-id or ANTHROPIC_AWS_WORKSPACE_ID (required)") + } + + // Base URL: --base-url > ANTHROPIC_AWS_BASE_URL > regional derivation. The + // AWS backend resolves this itself, so the first-party SDK-default/profile + // base-URL rows would mislead — show the AWS-resolved value instead. + fmt.Fprintln(out) + fmt.Fprintln(out, "Base URL") + switch { + case root.String("base-url") != "": + writeRow(out, true, "--base-url flag", root.String("base-url")) + case os.Getenv("ANTHROPIC_AWS_BASE_URL") != "": + writeRow(out, true, "ANTHROPIC_AWS_BASE_URL env", os.Getenv("ANTHROPIC_AWS_BASE_URL")) + case region != "": + writeRow(out, true, "Regional (derived from region)", fmt.Sprintf("https://aws-external-anthropic.%s.api.aws", region)) + default: + writeRow(out, true, "Regional (derived from region)", "(determined once region is set)") + } + + return nil +} + // activeProfileWithSource mirrors activeProfile but returns a human-readable // description of which tier resolved the active profile, for use in status // output. The global --profile flag's Sources include ANTHROPIC_PROFILE, so diff --git a/pkg/cmd/cmd_aws_test.go b/pkg/cmd/cmd_aws_test.go new file mode 100644 index 0000000..d9d36e9 --- /dev/null +++ b/pkg/cmd/cmd_aws_test.go @@ -0,0 +1,319 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "github.com/anthropics/anthropic-sdk-go/aws" + "github.com/anthropics/anthropic-sdk-go/option" +) + +// awsFlags returns the AWS tier flags (mirroring extras.go) plus the +// first-party flags the conflict check reads, so tests exercise the same +// flag layer production uses. +func awsFlags() []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{Name: "aws", Sources: cli.EnvVars("ANTHROPIC_USE_AWS")}, + &cli.StringFlag{Name: "aws-region", Sources: cli.EnvVars("AWS_REGION", "AWS_DEFAULT_REGION")}, + &cli.StringFlag{Name: "aws-workspace-id", Sources: cli.EnvVars("ANTHROPIC_AWS_WORKSPACE_ID")}, + &cli.StringFlag{Name: "aws-api-key", Sources: cli.EnvVars("ANTHROPIC_AWS_API_KEY")}, + &cli.StringFlag{Name: "base-url"}, + // First-party creds the AWS conflict notice inspects. + &cli.StringFlag{Name: "api-key"}, + &cli.StringFlag{Name: "auth-token"}, + &cli.StringFlag{Name: "profile", Sources: cli.EnvVars("ANTHROPIC_PROFILE")}, + &cli.StringFlag{Name: "identity-token"}, + &cli.StringFlag{Name: "identity-token-file"}, + &cli.StringFlag{Name: "federation-rule"}, + &cli.StringFlag{Name: "organization-id"}, + &cli.StringFlag{Name: "service-account-id"}, + } +} + +// clearAWSEnv unsets every env var the AWS flag Sources read, so ambient +// values on the test host (a developer laptop or EC2 instance commonly has +// AWS_REGION set) cannot leak into flag resolution. +func clearAWSEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "ANTHROPIC_USE_AWS", "AWS_REGION", "AWS_DEFAULT_REGION", + "ANTHROPIC_AWS_WORKSPACE_ID", "ANTHROPIC_AWS_API_KEY", "ANTHROPIC_AWS_BASE_URL", + "ANTHROPIC_PROFILE", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", + } { + clearEnv(t, k) + } +} + +// newAWSCmd builds a *cli.Command carrying awsFlags() with the given flag +// values pre-set, so helpers that read cmd.String/cmd.IsSet behave exactly as +// they do after real flag parsing. +func newAWSCmd(t *testing.T, set map[string]string) *cli.Command { + t.Helper() + cmd := &cli.Command{Flags: awsFlags()} + for k, v := range set { + require.NoError(t, cmd.Set(k, v)) + } + return cmd +} + +// TestBuildAWSConfig asserts buildAWSConfig copies the four flag values into +// the matching aws.ClientConfig fields — including the empty-flag case (empty +// in ⇒ empty out; the SDK does the env/regional fallback, not us). +func TestBuildAWSConfig(t *testing.T) { + for _, tc := range []struct { + name string + set map[string]string + want aws.ClientConfig + }{ + { + name: "all set", + set: map[string]string{ + "aws-workspace-id": "wrkspc_123", + "aws-region": "us-west-2", + "aws-api-key": "fake-aws-gateway-key", + "base-url": "https://staging.example.com", + }, + want: aws.ClientConfig{ + WorkspaceID: "wrkspc_123", + AWSRegion: "us-west-2", + APIKey: "fake-aws-gateway-key", + BaseURL: "https://staging.example.com", + }, + }, + { + name: "sigv4 mode (no api key)", + set: map[string]string{ + "aws-workspace-id": "wrkspc_456", + "aws-region": "eu-central-1", + }, + want: aws.ClientConfig{ + WorkspaceID: "wrkspc_456", + AWSRegion: "eu-central-1", + }, + }, + { + name: "all empty — SDK falls back", + set: map[string]string{}, + want: aws.ClientConfig{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + clearAWSEnv(t) + cmd := newAWSCmd(t, tc.set) + got := buildAWSConfig(cmd) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestAWSConflictWarning mirrors TestMultiAuthWarning: the AWS conflict notice +// fires when --aws is paired with a first-party credential (which --aws +// overrides), and is silent when only --aws-api-key (mode selector, not a +// cross-tier conflict) is set alongside --aws. +func TestAWSConflictWarning(t *testing.T) { + t.Run("warns on first-party api-key", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "api-key": "fake-first-party-key"}) + out := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.Contains(t, out, "--aws is active") + assert.Contains(t, out, "--api-key / ANTHROPIC_API_KEY") + }) + + t.Run("warns on auth-token", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "auth-token": "tok"}) + out := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.Contains(t, out, "--auth-token / ANTHROPIC_AUTH_TOKEN") + }) + + t.Run("warns on explicit profile", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "profile": "work"}) + out := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.Contains(t, out, "profile from --profile / ANTHROPIC_PROFILE") + }) + + t.Run("warns on federation env", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "federation-rule": "fdrl_x"}) + out := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.Contains(t, out, "federation env") + }) + + t.Run("silent for --aws-api-key alone (mode selector, must not warn)", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "aws-api-key": "fake-aws-gateway-key"}) + out := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.Empty(t, out, "ANTHROPIC_AWS_API_KEY selects API-key mode; it is not a cross-tier conflict") + }) + + t.Run("silent when no first-party cred set", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "aws-region": "us-west-2", "aws-workspace-id": "wrkspc_1"}) + out := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.Empty(t, out) + }) + + t.Run("emits once", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, map[string]string{"aws": "true", "api-key": "fake-first-party-key"}) + first := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + second := captureStderr(t, func() { warnIfAWSConflict(cmd) }) + assert.NotEmpty(t, first) + assert.Empty(t, second) + }) +} + +// runAWSStatus runs `auth status` against a root carrying the AWS tier flags +// (plus the first-party flags authStatus reads via c.Root()), passing the given +// global flag values as argv before the subcommand — mirroring how the binary +// parses them. (Values must come through parsing, not pre-Set: Run re-parses +// argv and resets flags.) Mirrors runStatus but for the AWS path. +func runAWSStatus(t *testing.T, set map[string]string) (string, error) { + t.Helper() + root := &cli.Command{ + Name: "ant", + Flags: awsFlags(), + Commands: []*cli.Command{{ + Name: "auth", Commands: []*cli.Command{{ + Name: "status", Action: authStatus, + }}, + }}, + } + argv := []string{"ant"} + for k, v := range set { + if k == "aws" { + argv = append(argv, "--aws") + continue + } + argv = append(argv, "--"+k, v) + } + argv = append(argv, "auth", "status") + return captureStdout(t, func() error { + return root.Run(t.Context(), argv) + }) +} + +// TestAWSPrecedenceInvariant guards the cross-site invariant the plan requires +// (ANT_AWS_PLAN.md "Three-site precedence invariant"): AWS is tier-0 and must +// win across all three sites that mirror each other — (a) the request-options +// short-circuit in getDefaultRequestOptions, (b) warnIfMultipleAuthSources' +// ordering, and (c) credWinner in authStatus. The test pairs --aws with the +// TOP first-party tier (--api-key, tier 1) so AWS is genuinely beating the +// strongest competing source, and asserts AWS wins at the two observable sites +// in lockstep. API-key mode keeps aws.NewClient network-free; resetWarnOnce +// isolates the shared one-shot guard. +func TestAWSPrecedenceInvariant(t *testing.T) { + awsAndTopFirstParty := map[string]string{ + "aws": "true", + "aws-region": "us-west-2", + "aws-workspace-id": "wrkspc_abc", + "aws-api-key": "fake-aws-gateway-key", + "api-key": "fake-first-party-secret", // tier-1 first-party; must be overridden + } + + t.Run("site (a): request options short-circuit before first-party switch", func(t *testing.T) { + clearAWSEnv(t) + resetWarnOnce(t) + cmd := newAWSCmd(t, awsAndTopFirstParty) + var opts []option.RequestOption + out := captureStderr(t, func() { + opts = getDefaultRequestOptions(cmd) + }) + // AWS branch ran (its conflict notice), NOT the first-party switch + // (which would emit the "multiple auth sources configured" notice). + assert.Contains(t, out, "--aws is active") + assert.NotContains(t, out, "multiple auth sources configured") + // Prove the AWS backend opts actually flowed through — not merely that + // the slice is non-empty (the base opts alone make it non-empty, so + // NotEmpty could never fail). The AWS branch returns base opts + + // awsClient.Options. Derive the base count empirically from a no-aws, + // no-credential invocation (self-calibrating — survives base-opt + // changes, no magic number), then build the AWS client independently + // (network-free in API-key mode) and assert the totals reconcile. + baseCmd := newAWSCmd(t, nil) // no flags set ⇒ switch appends nothing + baseCount := len(getDefaultRequestOptions(baseCmd)) + awsClient, err := aws.NewClient(context.Background(), buildAWSConfig(cmd)) + require.NoError(t, err) + require.NotEmpty(t, awsClient.Options, "sanity: AWS client should contribute options in API-key mode") + assert.Equal(t, baseCount+len(awsClient.Options), len(opts), + "getDefaultRequestOptions must return base opts + the AWS backend opts") + }) + + t.Run("site (c): auth status reports AWS winner, not the first-party api-key", func(t *testing.T) { + clearAWSEnv(t) + out, err := runAWSStatus(t, awsAndTopFirstParty) + require.NoError(t, err) + // AWS is the winner; the tier-1 first-party api-key is neither shown as + // the credential winner nor leaked. + assert.Contains(t, out, "Claude Platform on AWS") + assert.Contains(t, out, "AWS gateway (API key, x-api-key)") + assert.NotContains(t, out, "fake-first-party-secret") + assert.NotContains(t, out, "--api-key / ANTHROPIC_API_KEY") + }) +} + +// TestAuthStatusAWSRow asserts that when Bool("aws") is set, auth status shows +// AWS as the active backend with the correct mode label, and does NOT render +// the first-party profile/key/federation winner rows. +func TestAuthStatusAWSRow(t *testing.T) { + t.Run("API-key mode", func(t *testing.T) { + clearAWSEnv(t) + out, err := runAWSStatus(t, map[string]string{ + "aws": "true", + "aws-region": "us-west-2", + "aws-workspace-id": "wrkspc_abc", + "aws-api-key": "fake-aws-gateway-key-1234567890", + }) + require.NoError(t, err) + assert.Contains(t, out, "Claude Platform on AWS") + assert.Contains(t, out, "AWS gateway (API key, x-api-key)") + assert.Contains(t, out, "us-west-2") + assert.Contains(t, out, "wrkspc_abc") + // Regional base URL derived from region. + assert.Contains(t, out, "https://aws-external-anthropic.us-west-2.api.aws") + // Secret is redacted, not printed in full. + assert.NotContains(t, out, "fake-aws-gateway-key-1234567890") + // First-party winner rows must not appear. + assert.NotContains(t, out, "Active profile:") + assert.NotContains(t, out, "no credential configured") + }) + + t.Run("SigV4 mode", func(t *testing.T) { + clearAWSEnv(t) + out, err := runAWSStatus(t, map[string]string{ + "aws": "true", + "aws-region": "eu-central-1", + "aws-workspace-id": "wrkspc_xyz", + }) + require.NoError(t, err) + assert.Contains(t, out, "AWS gateway (SigV4)") + assert.Contains(t, out, "AWS credential chain") + assert.Contains(t, out, "eu-central-1") + assert.NotContains(t, out, "AWS gateway (API key") + }) + + t.Run("base-url override wins over regional derivation", func(t *testing.T) { + clearAWSEnv(t) + out, err := runAWSStatus(t, map[string]string{ + "aws": "true", + "aws-region": "us-west-2", + "aws-workspace-id": "wrkspc_abc", + "base-url": "https://staging.example.com", + }) + require.NoError(t, err) + assert.Contains(t, out, "https://staging.example.com") + assert.NotContains(t, out, "https://aws-external-anthropic.us-west-2.api.aws") + }) +} diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 8607371..3e705bb 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -18,6 +19,7 @@ import ( "syscall" "github.com/anthropics/anthropic-cli/internal/jsonview" + "github.com/anthropics/anthropic-sdk-go/aws" "github.com/anthropics/anthropic-sdk-go/config" "github.com/anthropics/anthropic-sdk-go/option" @@ -64,7 +66,48 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { option.WithHeader("X-Stainless-Runtime", "cli"), option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), } - // Credential precedence mirrors the WIF User Guide's "Credential resolution" section: + + // Tier 0 — Claude Platform on AWS. Opt-in only (--aws / ANTHROPIC_USE_AWS, + // both surfaced through cmd.Bool("aws")); short-circuits BEFORE the + // first-party credential switch below. Placed here — right after the base + // opts slice — so an AWS call never runs loadProfileIfUsable (disk I/O + + // possible client_id shadow-warning), builds the federation struct, or + // calls warnIfMultipleAuthSources, all of which would be wasted/misleading + // work for a request that bypasses the first-party paths entirely. + if cmd.Bool("aws") { + cfg := buildAWSConfig(cmd) + // context.Background(): getDefaultRequestOptions takes only *cli.Command + // (urfave/cli v3 exposes no Context on the Command; ctx flows separately + // to actions and is not threaded into this helper at its ~30 generated + // call sites). aws.NewClient uses ctx only for eager AWS credential + // resolution in SigV4 mode — there is no long-lived request I/O here. + // In SigV4 mode that resolution can walk the AWS credential chain + // (env → shared config → SSO → IMDS); on a misconfigured host it may + // block up to the AWS SDK's own internal per-provider timeouts before + // erroring. We deliberately do NOT impose a CLI-side deadline: a short + // one would break legitimately-slow chains (SSO, role assumption, IMDS + // on a busy host). The SDK's bounded timeouts are the backstop. + awsClient, err := aws.NewClient(context.Background(), cfg) + if err != nil { + // Surface the SDK's clean "no region" / "no workspace ID" / "no base + // URL" error rather than letting it fall through to a downstream 401. + // Fatals via os.Exit to match the federation path below (which carries + // its own TODO about bypassing urfave's error pipeline) rather than + // re-threading an error return through ~30 codegen handlers. + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + // Conflict notice: warn only when a FIRST-PARTY credential is also set + // (--aws wins, since it short-circuits here). ANTHROPIC_AWS_API_KEY + // (--aws-api-key) is NOT a conflict — it merely selects API-key mode — + // so it is deliberately excluded, else every API-key-mode CI run would + // spuriously warn. + warnIfAWSConflict(cmd) + return append(opts, awsClient.Options...) + } + + // Credential precedence mirrors the WIF User Guide's "Credential resolution" section, + // with the AWS tier-0 short-circuit above sitting ahead of all of them: // 1. --api-key / ANTHROPIC_API_KEY (flag or env; doc tiers 1+2) // 2. --auth-token / ANTHROPIC_AUTH_TOKEN (flag or env; doc tiers 1+2) // 3. profile named by --profile / ANTHROPIC_PROFILE (explicit) @@ -135,6 +178,65 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { return opts } +// buildAWSConfig maps the CLI's AWS flags onto the SDK's aws.ClientConfig. +// Pure and unit-tested directly (no network, no aws.NewClient). Empty values +// are intentional: awsauth.ResolveConfig treats an empty string in every field +// as "unset, fall back" — to the env vars (AWS_REGION, ANTHROPIC_AWS_API_KEY, +// ANTHROPIC_AWS_WORKSPACE_ID, ANTHROPIC_AWS_BASE_URL) and regional base-URL +// derivation — and emits its own clean "no region/workspace/base URL" errors. +// So no IsSet guards are needed; passing "" never clobbers anything. The +// --aws-api-key flag, when set, selects API-key mode; when empty the SDK reads +// ANTHROPIC_AWS_API_KEY then falls back to SigV4 via the AWS credential chain. +func buildAWSConfig(cmd *cli.Command) aws.ClientConfig { + return aws.ClientConfig{ + WorkspaceID: cmd.String("aws-workspace-id"), + AWSRegion: cmd.String("aws-region"), + APIKey: cmd.String("aws-api-key"), + BaseURL: cmd.String("base-url"), + } +} + +// warnIfAWSConflict emits a one-shot stderr notice when --aws is active AND a +// first-party credential is also configured. --aws wins (it short-circuits +// before the first-party switch), so the notice names the ignored source. +// Deliberately ignores --aws-api-key: its presence only selects API-key mode +// within the AWS tier, so it is not a cross-tier conflict. +func warnIfAWSConflict(cmd *cli.Command) { + var ignored []string + if cmd.IsSet("api-key") { + ignored = append(ignored, "--api-key / ANTHROPIC_API_KEY") + } + if cmd.IsSet("auth-token") { + ignored = append(ignored, "--auth-token / ANTHROPIC_AUTH_TOKEN") + } + if profileIsExplicit(cmd) { + ignored = append(ignored, "profile from --profile / ANTHROPIC_PROFILE") + } + fed := federation{ + Assertion: cmd.String("identity-token"), + AssertionFile: cmd.String("identity-token-file"), + Rule: cmd.String("federation-rule"), + OrganizationID: cmd.String("organization-id"), + ServiceAccountID: cmd.String("service-account-id"), + } + if fed.AnySet() { + ignored = append(ignored, "federation env") + } + if len(ignored) == 0 { + return + } + // Intentionally shares multiAuthWarnOnce with warnIfMultipleAuthSources: + // both emit a single "Note:" auth diagnostic, and the AWS short-circuit + // returns before warnIfMultipleAuthSources is ever reached, so within one + // process exactly one of the two paths runs — the shared Once can't let one + // silence the other. + multiAuthWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, + "Note: --aws is active and overrides the first-party credential(s) also configured (%s). Run `ant --aws auth status` for details.\n", + strings.Join(ignored, ", ")) + }) +} + // warnIfMultipleAuthSources emits a one-shot stderr notice when more than one // credential source is configured, naming the sources and the precedence // winner. No secret values are printed. Order matches the User Guide's diff --git a/pkg/cmd/extras.go b/pkg/cmd/extras.go index a605426..477cc96 100644 --- a/pkg/cmd/extras.go +++ b/pkg/cmd/extras.go @@ -39,5 +39,30 @@ func init() { Usage: "Optional service-account tagged ID (svac_...) for target_type=SERVICE_ACCOUNT federation rules.", Sources: cli.EnvVars("ANTHROPIC_SERVICE_ACCOUNT_ID"), }, + // Claude Platform on AWS credential tier. Opt-in only via --aws or the + // dedicated ANTHROPIC_USE_AWS toggle (not ambient AWS env vars); when + // active, getDefaultRequestOptions short-circuits to the SDK's AWS + // gateway backend (SigV4 or x-api-key) ahead of the first-party + // precedence switch. + &cli.BoolFlag{ + Name: "aws", + Usage: "Route requests through the Claude Platform on AWS gateway (SigV4 via the AWS credential chain, or x-api-key when --aws-api-key is set). Persistent CI toggle: ANTHROPIC_USE_AWS.", + Sources: cli.EnvVars("ANTHROPIC_USE_AWS"), + }, + &cli.StringFlag{ + Name: "aws-region", + Usage: "AWS region for the gateway URL and SigV4 signing scope (required with --aws). Resolves from --aws-region > AWS_REGION > AWS_DEFAULT_REGION; NOT from the AWS profile's config.", + Sources: cli.EnvVars("AWS_REGION", "AWS_DEFAULT_REGION"), + }, + &cli.StringFlag{ + Name: "aws-workspace-id", + Usage: "Anthropic workspace ID (wrkspc_...) sent as the anthropic-workspace-id header (required with --aws).", + Sources: cli.EnvVars("ANTHROPIC_AWS_WORKSPACE_ID"), + }, + &cli.StringFlag{ + Name: "aws-api-key", + Usage: "Optional Anthropic API key for the AWS gateway (x-api-key). Its presence selects API-key mode; when unset, SigV4 is used via the default AWS credential chain.", + Sources: cli.EnvVars("ANTHROPIC_AWS_API_KEY"), + }, ) }