diff --git a/README.md b/README.md index 4d33c63..397065c 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,14 @@ prism anthropic auth remove browser handles account selection, SSO, and MFA. Prism does not import the credential used by an existing Claude Code login. -This first release stores and refreshes Anthropic credentials for account and -usage management. `prism claude` inference continues to use Prism's existing -providers until native Anthropic routing is added separately. +This stores and refreshes Anthropic credentials for account and usage management, +and `prism claude` forwards requests through a local bridge while keeping +Claude Code’s existing OAuth/login mode. + +`prism anthropic auth remove` deletes the Prism grant and its routing/usage +state. Anthropic does not document a revocation endpoint for this grant, so the +command does not claim provider-side revocation; use Anthropic account security +settings when provider-side invalidation is required. ## ChatGPT @@ -149,7 +154,13 @@ prism claude --model gpt-5.6-sol --effort ultracode These examples use `gpt-5.6-sol`; replace it with another Prism-supported model when needed. `prism claude` launches the installed Claude Code CLI and -passes its arguments through unchanged. +passes its arguments through unchanged. Claude models use the registered +Anthropic account pool automatically. To start a session on one account, pass +its alias or redacted id before the Claude Code arguments: + +```sh +prism claude --account work-admin --model claude-fable-5 +``` Verify the setup: diff --git a/internal/anthropic/oauth_test.go b/internal/anthropic/oauth_test.go index 9672096..481c681 100644 --- a/internal/anthropic/oauth_test.go +++ b/internal/anthropic/oauth_test.go @@ -85,3 +85,85 @@ func TestLoginRejectsACallbackWithTheWrongState(t *testing.T) { t.Fatalf("error = %v", err) } } + +func TestLoginRejectsTheWrongCallbackHostWithoutConsumingTheGrant(t *testing.T) { + oauth := OAuth{ + Timeout: time.Second, + OpenBrowser: func(target string) error { + authorize, _ := url.Parse(target) + redirect := authorize.Query().Get("redirect_uri") + state := authorize.Query().Get("state") + go func() { + request, _ := http.NewRequest(http.MethodGet, redirect+"?code=wrong-host&state="+url.QueryEscape(state), nil) + request.Host = "attacker.example" + response, err := http.DefaultClient.Do(request) + if err == nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + response, err = http.Get(redirect + "?code=valid-code&state=" + url.QueryEscape(state)) + if err == nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + }() + return nil + }, + } + grant, err := oauth.Login(context.Background()) + if err != nil { + t.Fatal(err) + } + if grant.AuthorizationCode != "valid-code" { + t.Fatalf("authorization code = %q", grant.AuthorizationCode) + } +} + +func TestLoginConsumesOnlyTheFirstValidCallback(t *testing.T) { + oauth := OAuth{ + Timeout: time.Second, + OpenBrowser: func(target string) error { + authorize, _ := url.Parse(target) + redirect := authorize.Query().Get("redirect_uri") + state := url.QueryEscape(authorize.Query().Get("state")) + for _, code := range []string{"first-code", "second-code"} { + response, err := http.Get(redirect + "?code=" + code + "&state=" + state) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + return nil + }, + } + grant, err := oauth.Login(context.Background()) + if err != nil { + t.Fatal(err) + } + if grant.AuthorizationCode != "first-code" { + t.Fatalf("authorization code = %q", grant.AuthorizationCode) + } +} + +func TestLoginTimesOutAndHonorsCancellation(t *testing.T) { + for _, test := range []struct { + name string + ctx func() context.Context + }{ + {name: "timeout", ctx: func() context.Context { return context.Background() }}, + {name: "cancelled", ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }}, + } { + t.Run(test.name, func(t *testing.T) { + oauth := OAuth{Timeout: 20 * time.Millisecond, OpenBrowser: func(string) error { return nil }} + _, err := oauth.Login(test.ctx()) + if err == nil || !strings.Contains(err.Error(), "timed out or was cancelled") { + t.Fatalf("error = %v", err) + } + }) + } +} diff --git a/internal/cli/claude.go b/internal/cli/claude.go index 22218a1..c5c69b7 100644 --- a/internal/cli/claude.go +++ b/internal/cli/claude.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/subtle" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -20,9 +21,10 @@ import ( ) type claudeBridge struct { - server *http.Server - url string - credential string + server *http.Server + url string + headerName string + headerValue string } func runClaudeCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { @@ -44,17 +46,22 @@ func runClaudeCommand(ctx context.Context, args []string, stdout io.Writer, stde } return loginProvider(ctx, "anthropic", client, stdout) } + account, remainingArgs, err := parseClaudeOptions(args) + if err != nil { + return err + } client, err := prismClient(ctx, commonOptions{}) if err != nil { return err } - return runClaude(ctx, client.BaseURL, client.Token, args, os.Stdin, stdout, stderr) + return runClaude(ctx, client.BaseURL, client.Token, account, remainingArgs, os.Stdin, stdout, stderr) } func runClaude( ctx context.Context, prismURL string, prismCredential string, + prismAccount string, args []string, stdin io.Reader, stdout io.Writer, @@ -64,7 +71,7 @@ func runClaude( if err != nil { return errors.New("Claude Code is not installed or is not on PATH") } - bridge, err := startClaudeBridge(prismURL, prismCredential, stderr) + bridge, err := startClaudeBridge(prismURL, prismCredential, prismAccount, stderr) if err != nil { return err } @@ -74,7 +81,11 @@ func runClaude( command.Stdin = stdin command.Stdout = stdout command.Stderr = stderr - command.Env = claudeEnvironment(os.Environ(), bridge.url, bridge.credential) + command.Env = claudeEnvironment( + os.Environ(), + bridge.url, + bridge.headerName+": "+bridge.headerValue, + ) if err := command.Run(); err != nil { var exitError *exec.ExitError if errors.As(err, &exitError) { @@ -85,7 +96,7 @@ func runClaude( return nil } -func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer) (*claudeBridge, error) { +func startClaudeBridge(prismURL string, prismCredential string, prismAnthropicAccount string, stderr io.Writer) (*claudeBridge, error) { target, err := url.Parse(prismURL) if err != nil || (target.Scheme != "https" && target.Scheme != "http") || target.Host == "" { return nil, errors.New("Prism URL is invalid") @@ -93,11 +104,15 @@ func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer if strings.TrimSpace(prismCredential) == "" || strings.ContainsAny(prismCredential, " \t\r\n") { return nil, errors.New("Circles credential is invalid") } + if strings.ContainsAny(prismAnthropicAccount, "\r\n") { + return nil, errors.New("Anthropic account selector is invalid") + } credentialBytes := make([]byte, 32) if _, err := rand.Read(credentialBytes); err != nil { return nil, errors.New("could not create a local Claude credential") } - localCredential := hex.EncodeToString(credentialBytes) + localHeaderName := "X-Prism-Claude-Bridge" + localHeaderValue := hex.EncodeToString(credentialBytes) proxy := httputil.NewSingleHostReverseProxy(target) director := proxy.Director @@ -105,7 +120,11 @@ func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer director(request) request.Host = target.Host request.Header.Del("X-Api-Key") + request.Header.Del(localHeaderName) request.Header.Set("Authorization", "Bearer "+prismCredential) + if prismAnthropicAccount != "" { + request.Header.Set("X-Prism-Anthropic-Account", "b64:"+base64.RawURLEncoding.EncodeToString([]byte(prismAnthropicAccount))) + } } proxy.ErrorLog = log.New(stderr, "prism: ", 0) proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, _ error) { @@ -114,8 +133,8 @@ func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if subtle.ConstantTimeCompare( - []byte(request.Header.Get("Authorization")), - []byte("Bearer "+localCredential), + []byte(request.Header.Get(localHeaderName)), + []byte(localHeaderValue), ) != 1 { http.Error(response, "Unauthorized", http.StatusUnauthorized) return @@ -136,9 +155,10 @@ func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer _ = server.Serve(listener) }() return &claudeBridge{ - server: server, - url: "http://" + listener.Addr().String(), - credential: localCredential, + server: server, + url: "http://" + listener.Addr().String(), + headerName: localHeaderName, + headerValue: localHeaderValue, }, nil } @@ -148,12 +168,13 @@ func (bridge *claudeBridge) close() { _ = bridge.server.Shutdown(ctx) } -func claudeEnvironment(environment []string, baseURL string, credential string) []string { +func claudeEnvironment(environment []string, baseURL string, customHeaders string) []string { filtered := make([]string, 0, len(environment)+2) for _, entry := range environment { name, _, _ := strings.Cut(entry, "=") switch strings.ToUpper(name) { case "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_USE_BEDROCK", @@ -161,24 +182,57 @@ func claudeEnvironment(environment []string, baseURL string, credential string) "ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_VERTEX_BASE_URL", "ANTHROPIC_VERTEX_PROJECT_ID", - "CLOUD_ML_REGION": + "CLOUD_ML_REGION", + "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL": continue } filtered = append(filtered, entry) } return append(filtered, "ANTHROPIC_BASE_URL="+baseURL, - "ANTHROPIC_AUTH_TOKEN="+credential, + "ANTHROPIC_CUSTOM_HEADERS="+customHeaders, + "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1", ) } func printClaudeHelp(output io.Writer) { _, _ = fmt.Fprintln(output, `Usage: prism claude login [--profile ] - prism claude [claude arguments...] + prism claude [--account ] [claude arguments...] Pass --model with any model supported by Prism. +Use --account to target a specific Claude account on Prism. Uses the current Circles profile. Run 'crcl auth status' to list profiles and 'crcl use ' to switch before launching Claude Code. Run 'claude --help' for Claude Code options.`) } + +func parseClaudeOptions(args []string) (account string, passthroughArgs []string, err error) { + for index := 0; index < len(args); index++ { + argument := args[index] + switch { + case argument == "--": + return account, append(passthroughArgs, args[index:]...), nil + case argument == "--account": + if account != "" { + return "", nil, errors.New("--account may be specified only once") + } + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" || args[index] == "--" { + return "", nil, errors.New("--account requires a value") + } + account = strings.TrimSpace(args[index]) + case strings.HasPrefix(argument, "--account="): + if account != "" { + return "", nil, errors.New("--account may be specified only once") + } + account = strings.TrimSpace(strings.TrimPrefix(argument, "--account=")) + if account == "" { + return "", nil, errors.New("--account requires a value") + } + default: + passthroughArgs = append(passthroughArgs, argument) + } + } + return account, passthroughArgs, nil +} diff --git a/internal/cli/claude_test.go b/internal/cli/claude_test.go index a0f6743..fa48e86 100644 --- a/internal/cli/claude_test.go +++ b/internal/cli/claude_test.go @@ -6,6 +6,11 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" "strings" "sync/atomic" "testing" @@ -25,30 +30,118 @@ func TestClaudeHelpDoesNotResolveCredentials(t *testing.T) { } } -func TestClaudeEnvironmentReplacesExistingAnthropicCredentials(t *testing.T) { +func TestParseClaudeOptionsStripsAccountAndPreservesOrder(t *testing.T) { + account, remainingArgs, err := parseClaudeOptions([]string{ + "--model", "gpt-5.6-sol", + "--account", "acct-01", + "--print", + "--", + "say hi", + }) + if err != nil { + t.Fatal(err) + } + if account != "acct-01" { + t.Fatalf("account = %q", account) + } + if !reflect.DeepEqual(remainingArgs, []string{"--model", "gpt-5.6-sol", "--print", "--", "say hi"}) { + t.Fatalf("remainingArgs = %#v", remainingArgs) + } +} + +func TestParseClaudeOptionsRequiresValue(t *testing.T) { + for _, args := range [][]string{{"--account"}, {"--account", ""}, {"--account", "--"}, {"--account="}} { + _, _, err := parseClaudeOptions(args) + if err == nil || err.Error() != "--account requires a value" { + t.Fatalf("args/error = %#v/%v", args, err) + } + } +} + +func TestParseClaudeOptionsStopsAtClaudeArgumentSeparator(t *testing.T) { + account, remainingArgs, err := parseClaudeOptions([]string{"--account=work-admin", "--", "--account", "prompt-value"}) + if err != nil { + t.Fatal(err) + } + if account != "work-admin" || !reflect.DeepEqual(remainingArgs, []string{"--", "--account", "prompt-value"}) { + t.Fatalf("account/remainingArgs = %q/%#v", account, remainingArgs) + } +} + +func TestParseClaudeOptionsRejectsDuplicateAccount(t *testing.T) { + _, _, err := parseClaudeOptions([]string{"--account", "Personal", "--account=Team"}) + if err == nil || err.Error() != "--account may be specified only once" { + t.Fatalf("error = %v", err) + } +} + +func TestRunClaudeCommandPreservesClaudeArgumentsWithoutAccount(t *testing.T) { + commandDir := t.TempDir() + command := filepath.Join(commandDir, "claude") + if runtime.GOOS == "windows" { + command += ".exe" + } + source := filepath.Join(commandDir, "main.go") + if err := os.WriteFile(source, []byte(`package main +import ( + "fmt" + "os" +) + +func main() { + for _, arg := range os.Args[1:] { + fmt.Println(arg) + } + } +`), 0o644); err != nil { + t.Fatal(err) + } + if err := exec.Command("go", "build", "-o", command, source).Run(); err != nil { + t.Fatalf("build fake claude command = %v", err) + } + + t.Setenv("PATH", commandDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CIRCLES_AUTH_TOKEN", "circles-secret") + + var stdout bytes.Buffer + err := runClaudeCommand(context.Background(), []string{"--account", "acct-01", "--model", "gpt-5.6-sol", "--print", "--", "say hi"}, &stdout, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if got := stdout.String(); got != "--model\ngpt-5.6-sol\n--print\n--\nsay hi\n" { + t.Fatalf("stdout = %q", got) + } +} + +func TestClaudeEnvironmentPreservesClaudeLoginModeWithoutTokenInjection(t *testing.T) { environment := claudeEnvironment([]string{ "PATH=/usr/bin", "ANTHROPIC_BASE_URL=https://api.anthropic.com", "ANTHROPIC_AUTH_TOKEN=old-token", "ANTHROPIC_API_KEY=old-key", + "ANTHROPIC_CUSTOM_HEADERS=Authorization: Bearer old-bridge-token", + "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=0", "CLAUDE_CODE_USE_BEDROCK=1", "CLAUDE_CODE_USE_VERTEX=1", "ANTHROPIC_BEDROCK_BASE_URL=https://bedrock.example.com", "ANTHROPIC_VERTEX_BASE_URL=https://vertex.example.com", "ANTHROPIC_VERTEX_PROJECT_ID=example-project", "CLOUD_ML_REGION=us-east5", - }, "http://127.0.0.1:12345", "local-token") + "CLAUDE_CODE_OAUTH_TOKEN=existing-login-token", + }, "http://127.0.0.1:12345", "X-Prism-Claude-Bridge-abc: 123456") joined := strings.Join(environment, "\n") for _, unwanted := range []string{ "api.anthropic.com", "old-token", "old-key", + "Authorization: Bearer old-bridge-token", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "bedrock.example.com", "vertex.example.com", "example-project", "us-east5", + "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=0", } { if strings.Contains(joined, unwanted) { t.Fatalf("environment retained %q: %s", unwanted, joined) @@ -56,17 +149,28 @@ func TestClaudeEnvironmentReplacesExistingAnthropicCredentials(t *testing.T) { } for _, wanted := range []string{ "PATH=/usr/bin", + "CLAUDE_CODE_OAUTH_TOKEN=existing-login-token", "ANTHROPIC_BASE_URL=http://127.0.0.1:12345", - "ANTHROPIC_AUTH_TOKEN=local-token", + "ANTHROPIC_CUSTOM_HEADERS=X-Prism-Claude-Bridge-abc: 123456", + "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1", } { if !strings.Contains(joined, wanted) { t.Fatalf("environment omitted %q: %s", wanted, joined) } } + if strings.Contains(joined, "ANTHROPIC_AUTH_TOKEN=") { + t.Fatalf("environment still contains ANTHROPIC_AUTH_TOKEN: %s", joined) + } + if strings.Contains(joined, "ANTHROPIC_API_KEY=") { + t.Fatalf("environment still contains ANTHROPIC_API_KEY: %s", joined) + } } func TestClaudeBridgeAuthenticatesLocallyAndForwardsTheCirclesCredential(t *testing.T) { var requests atomic.Int32 + var seenIncomingOAuth atomic.Bool + var seenBridgeHeader atomic.Bool + var observedCustomHeader atomic.Value upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { requests.Add(1) if request.URL.Path != "/v1/messages" || request.URL.RawQuery != "beta=true" { @@ -75,6 +179,14 @@ func TestClaudeBridgeAuthenticatesLocallyAndForwardsTheCirclesCredential(t *test if request.Header.Get("Authorization") != "Bearer circles-secret" { t.Errorf("authorization = %q", request.Header.Get("Authorization")) } + if request.Header.Get("Authorization") == "Bearer incoming-claude-oauth-token" { + seenIncomingOAuth.Store(true) + } + if observed := observedCustomHeader.Load(); observed != nil { + if request.Header.Get(observed.(string)) != "" { + seenBridgeHeader.Store(true) + } + } if request.Header.Get("X-Api-Key") != "" { t.Errorf("x-api-key = %q", request.Header.Get("X-Api-Key")) } @@ -83,11 +195,38 @@ func TestClaudeBridgeAuthenticatesLocallyAndForwardsTheCirclesCredential(t *test })) defer upstream.Close() - bridge, err := startClaudeBridge(upstream.URL, "circles-secret", io.Discard) + bridge, err := startClaudeBridge(upstream.URL, "circles-secret", "", io.Discard) if err != nil { t.Fatal(err) } defer bridge.close() + observedCustomHeader.Store(bridge.headerName) + if bridge.headerName == "" || bridge.headerValue == "" { + t.Fatal("missing bridge auth header") + } + + if bridge.url == "" || strings.HasPrefix(bridge.url, "https://") { + t.Fatal("bridge URL is invalid") + } + + accountRequest, err := http.NewRequestWithContext( + t.Context(), http.MethodPost, bridge.url+"/v1/messages?beta=true", strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + accountRequest.Header.Set("Authorization", "Bearer incoming-claude-oauth-token") + accountRequest.Header.Set(bridge.headerName, "wrong-token") + wrong, err := http.DefaultClient.Do(accountRequest) + if err != nil { + t.Fatal(err) + } + if err := wrong.Body.Close(); err != nil { + t.Fatal(err) + } + if wrong.StatusCode != http.StatusUnauthorized || requests.Load() != 0 { + t.Fatalf("wrong header status/requests = %d/%d", wrong.StatusCode, requests.Load()) + } unauthorizedRequest, err := http.NewRequestWithContext( t.Context(), http.MethodPost, bridge.url+"/v1/messages?beta=true", strings.NewReader("{}"), @@ -113,7 +252,8 @@ func TestClaudeBridgeAuthenticatesLocallyAndForwardsTheCirclesCredential(t *test if err != nil { t.Fatal(err) } - request.Header.Set("Authorization", "Bearer "+bridge.credential) + request.Header.Set(bridge.headerName, bridge.headerValue) + request.Header.Set("Authorization", "Bearer incoming-claude-oauth-token") request.Header.Set("X-Api-Key", "remove-me") response, err := http.DefaultClient.Do(request) if err != nil { @@ -130,4 +270,131 @@ func TestClaudeBridgeAuthenticatesLocallyAndForwardsTheCirclesCredential(t *test if requests.Load() != 1 { t.Fatalf("upstream requests = %d", requests.Load()) } + if seenIncomingOAuth.Load() { + t.Fatal("incoming OAuth authorization leaked upstream") + } + if seenBridgeHeader.Load() { + t.Fatalf("bridge header %q leaked upstream", bridge.headerName) + } +} + +func TestClaudeBridgeProxyErrorsDoNotLogCredentials(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + target := upstream.URL + upstream.Close() + var stderr bytes.Buffer + bridge, err := startClaudeBridge(target, "circles-secret", "", &stderr) + if err != nil { + t.Fatal(err) + } + defer bridge.close() + + request, err := http.NewRequestWithContext( + t.Context(), http.MethodPost, bridge.url+"/v1/messages", strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set(bridge.headerName, bridge.headerValue) + request.Header.Set("Authorization", "Bearer incoming-claude-oauth-token") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusBadGateway { + t.Fatalf("status/body = %d/%q", response.StatusCode, body) + } + combined := stderr.String() + string(body) + for _, secret := range []string{ + "incoming-claude-oauth-token", + "circles-secret", + bridge.headerValue, + } { + if strings.Contains(combined, secret) { + t.Fatalf("proxy error leaked a credential: %q", combined) + } + } +} + +func TestClaudeBridgeInjectsAnthropicAccountHeader(t *testing.T) { + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + requests.Add(1) + if request.Header.Get("X-Prism-Anthropic-Account") != "b64:YWNjdC0wMQ" { + t.Errorf("account header = %q", request.Header.Get("X-Prism-Anthropic-Account")) + } + if request.Header.Get("X-Custom") != "keep-me" { + t.Errorf("custom header = %q", request.Header.Get("X-Custom")) + } + response.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(response, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + })) + defer upstream.Close() + + bridge, err := startClaudeBridge(upstream.URL, "circles-secret", "acct-01", io.Discard) + if err != nil { + t.Fatal(err) + } + defer bridge.close() + + request, err := http.NewRequestWithContext( + t.Context(), http.MethodPost, bridge.url+"/v1/messages?beta=true", strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set(bridge.headerName, bridge.headerValue) + request.Header.Set("Authorization", "Bearer incoming-claude-oauth-token") + request.Header.Set("X-Custom", "keep-me") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || !strings.Contains(string(body), "message_stop") { + t.Fatalf("response = %d %q", response.StatusCode, body) + } + if requests.Load() != 1 { + t.Fatalf("upstream requests = %d", requests.Load()) + } +} + +func TestClaudeBridgeEncodesUnicodeAnthropicAccountHeader(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-Prism-Anthropic-Account") != "b64:7YyAIOqzhOyglSDigJQg7JiI7Iuc" { + t.Errorf("account header = %q", request.Header.Get("X-Prism-Anthropic-Account")) + } + response.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + bridge, err := startClaudeBridge(upstream.URL, "circles-secret", "팀 계정 — 예시", io.Discard) + if err != nil { + t.Fatal(err) + } + defer bridge.close() + + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, bridge.url+"/v1/messages", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer incoming-claude-oauth-token") + request.Header.Set(bridge.headerName, bridge.headerValue) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d", response.StatusCode) + } } diff --git a/internal/cli/run.go b/internal/cli/run.go index de7c58c..7e122ec 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -153,11 +153,19 @@ func Run( if err := client.Remove(ctx, positionals[0]); err != nil { return err } - fmt.Fprintf(stdout, "Removed %s credential %s.\n", providerName, positionals[0]) + printRemoveConfirmation(stdout, providerName, positionals[0]) } return nil } +func printRemoveConfirmation(output io.Writer, provider string, credentialID string) { + if provider == "anthropic" { + fmt.Fprintf(output, "Removed anthropic credential %s from Prism. Anthropic provider-side revocation was not called; use Anthropic account security settings if needed.\n", credentialID) + return + } + fmt.Fprintf(output, "Removed %s credential %s.\n", provider, credentialID) +} + func runCombinedUsage(ctx context.Context, args []string, output io.Writer) error { options, positionals, err := parseCommonOptions(args) if err != nil { @@ -737,7 +745,7 @@ func printHelp(output io.Writer) { fmt.Fprintln(output, `Prism provider credential manager and client configuration tool Usage: - prism claude [claude arguments...] + prism claude [--account ] [claude arguments...] prism codex enable|disable|status prism usage [--profile ] prism chatgpt usage [--profile ] diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 7819a01..72ce87e 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -508,3 +508,22 @@ func TestOAuthLoginRejectsCallerChosenAccountIdentity(t *testing.T) { t.Fatalf("error = %v", err) } } + +func TestAnthropicRemovalExplainsTheProviderRevocationGap(t *testing.T) { + var output bytes.Buffer + printRemoveConfirmation(&output, "anthropic", "01j00000000000000000000002") + text := output.String() + if !strings.Contains(text, "Removed anthropic credential 01j00000000000000000000002 from Prism") || + !strings.Contains(text, "provider-side revocation was not called") || + !strings.Contains(text, "Anthropic account security settings") { + t.Fatalf("output = %q", text) + } +} + +func TestOtherProviderRemovalMessageIsUnchanged(t *testing.T) { + var output bytes.Buffer + printRemoveConfirmation(&output, "chatgpt", "01j00000000000000000000002") + if output.String() != "Removed chatgpt credential 01j00000000000000000000002.\n" { + t.Fatalf("output = %q", output.String()) + } +}