From 4d00a831ce238724f7af740201e6674dfd20a71e Mon Sep 17 00:00:00 2001 From: YG Park Date: Sat, 29 Aug 2026 17:56:57 +0900 Subject: [PATCH 1/2] feat: pool Gemini subscriptions through Prism --- README.md | 33 +- go.mod | 7 +- go.sum | 16 + internal/cli/gemini.go | 195 +--- internal/cli/gemini_test.go | 122 +- internal/cli/gemini_usage.go | 111 +- internal/cli/run.go | 41 +- internal/cli/run_test.go | 11 +- internal/gemini/antigravity.go | 128 +++ internal/gemini/antigravity_test.go | 75 ++ internal/gemini/oauth.go | 3 + .../danieljoos/wincred/.gitattributes | 1 + .../github.com/danieljoos/wincred/.gitignore | 25 + vendor/github.com/danieljoos/wincred/LICENSE | 21 + .../github.com/danieljoos/wincred/README.md | 145 +++ .../danieljoos/wincred/conversion.go | 110 ++ .../wincred/conversion_unsupported.go | 11 + vendor/github.com/danieljoos/wincred/sys.go | 151 +++ .../danieljoos/wincred/sys_unsupported.go | 38 + vendor/github.com/danieljoos/wincred/types.go | 69 ++ .../github.com/danieljoos/wincred/wincred.go | 114 ++ vendor/github.com/godbus/dbus/v5/.cirrus.yml | 11 + .../github.com/godbus/dbus/v5/.golangci.yml | 13 + .../github.com/godbus/dbus/v5/CONTRIBUTING.md | 50 + vendor/github.com/godbus/dbus/v5/LICENSE | 25 + vendor/github.com/godbus/dbus/v5/MAINTAINERS | 3 + vendor/github.com/godbus/dbus/v5/README.md | 47 + vendor/github.com/godbus/dbus/v5/SECURITY.md | 13 + vendor/github.com/godbus/dbus/v5/auth.go | 257 +++++ .../godbus/dbus/v5/auth_anonymous.go | 16 + .../godbus/dbus/v5/auth_default_other.go | 7 + .../godbus/dbus/v5/auth_default_windows.go | 5 + .../godbus/dbus/v5/auth_external.go | 26 + .../godbus/dbus/v5/auth_sha1_windows.go | 109 ++ vendor/github.com/godbus/dbus/v5/call.go | 66 ++ vendor/github.com/godbus/dbus/v5/conn.go | 1009 +++++++++++++++++ .../github.com/godbus/dbus/v5/conn_darwin.go | 36 + .../github.com/godbus/dbus/v5/conn_other.go | 83 ++ vendor/github.com/godbus/dbus/v5/conn_unix.go | 40 + .../github.com/godbus/dbus/v5/conn_windows.go | 13 + vendor/github.com/godbus/dbus/v5/dbus.go | 427 +++++++ vendor/github.com/godbus/dbus/v5/decoder.go | 376 ++++++ .../godbus/dbus/v5/default_handler.go | 338 ++++++ vendor/github.com/godbus/dbus/v5/doc.go | 70 ++ vendor/github.com/godbus/dbus/v5/encoder.go | 235 ++++ vendor/github.com/godbus/dbus/v5/escape.go | 84 ++ vendor/github.com/godbus/dbus/v5/export.go | 484 ++++++++ vendor/github.com/godbus/dbus/v5/match.go | 89 ++ vendor/github.com/godbus/dbus/v5/message.go | 393 +++++++ vendor/github.com/godbus/dbus/v5/object.go | 181 +++ vendor/github.com/godbus/dbus/v5/sequence.go | 24 + .../godbus/dbus/v5/sequential_handler.go | 125 ++ .../godbus/dbus/v5/server_interfaces.go | 107 ++ vendor/github.com/godbus/dbus/v5/sig.go | 298 +++++ .../godbus/dbus/v5/transport_darwin.go | 6 + .../godbus/dbus/v5/transport_generic.go | 52 + .../godbus/dbus/v5/transport_nonce_tcp.go | 41 + .../godbus/dbus/v5/transport_tcp.go | 41 + .../godbus/dbus/v5/transport_unix.go | 291 +++++ .../dbus/v5/transport_unixcred_dragonfly.go | 95 ++ .../dbus/v5/transport_unixcred_freebsd.go | 94 ++ .../dbus/v5/transport_unixcred_linux.go | 25 + .../dbus/v5/transport_unixcred_netbsd.go | 14 + .../dbus/v5/transport_unixcred_openbsd.go | 14 + .../godbus/dbus/v5/transport_zos.go | 6 + vendor/github.com/godbus/dbus/v5/variant.go | 169 +++ .../godbus/dbus/v5/variant_lexer.go | 284 +++++ .../godbus/dbus/v5/variant_parser.go | 815 +++++++++++++ .../zalando/go-keyring/.catwatch.yml | 1 + .../github.com/zalando/go-keyring/.gitignore | 23 + .../github.com/zalando/go-keyring/.zappr.yml | 8 + .../zalando/go-keyring/CONTRIBUTING.md | 12 + vendor/github.com/zalando/go-keyring/LICENSE | 21 + .../github.com/zalando/go-keyring/MAINTAINERS | 2 + .../github.com/zalando/go-keyring/README.md | 269 +++++ .../github.com/zalando/go-keyring/SECURITY.md | 8 + .../go-keyring/internal/shellescape/LICENSE | 21 + .../internal/shellescape/shellescape.go | 39 + .../github.com/zalando/go-keyring/keyring.go | 50 + .../zalando/go-keyring/keyring_darwin.go | 140 +++ .../zalando/go-keyring/keyring_fallback.go | 27 + .../zalando/go-keyring/keyring_mock.go | 71 ++ .../zalando/go-keyring/keyring_unix.go | 182 +++ .../zalando/go-keyring/keyring_windows.go | 103 ++ .../secret_service/secret_service.go | 257 +++++ vendor/modules.txt | 11 + 86 files changed, 9342 insertions(+), 337 deletions(-) create mode 100644 internal/gemini/antigravity.go create mode 100644 internal/gemini/antigravity_test.go create mode 100644 vendor/github.com/danieljoos/wincred/.gitattributes create mode 100644 vendor/github.com/danieljoos/wincred/.gitignore create mode 100644 vendor/github.com/danieljoos/wincred/LICENSE create mode 100644 vendor/github.com/danieljoos/wincred/README.md create mode 100644 vendor/github.com/danieljoos/wincred/conversion.go create mode 100644 vendor/github.com/danieljoos/wincred/conversion_unsupported.go create mode 100644 vendor/github.com/danieljoos/wincred/sys.go create mode 100644 vendor/github.com/danieljoos/wincred/sys_unsupported.go create mode 100644 vendor/github.com/danieljoos/wincred/types.go create mode 100644 vendor/github.com/danieljoos/wincred/wincred.go create mode 100644 vendor/github.com/godbus/dbus/v5/.cirrus.yml create mode 100644 vendor/github.com/godbus/dbus/v5/.golangci.yml create mode 100644 vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md create mode 100644 vendor/github.com/godbus/dbus/v5/LICENSE create mode 100644 vendor/github.com/godbus/dbus/v5/MAINTAINERS create mode 100644 vendor/github.com/godbus/dbus/v5/README.md create mode 100644 vendor/github.com/godbus/dbus/v5/SECURITY.md create mode 100644 vendor/github.com/godbus/dbus/v5/auth.go create mode 100644 vendor/github.com/godbus/dbus/v5/auth_anonymous.go create mode 100644 vendor/github.com/godbus/dbus/v5/auth_default_other.go create mode 100644 vendor/github.com/godbus/dbus/v5/auth_default_windows.go create mode 100644 vendor/github.com/godbus/dbus/v5/auth_external.go create mode 100644 vendor/github.com/godbus/dbus/v5/auth_sha1_windows.go create mode 100644 vendor/github.com/godbus/dbus/v5/call.go create mode 100644 vendor/github.com/godbus/dbus/v5/conn.go create mode 100644 vendor/github.com/godbus/dbus/v5/conn_darwin.go create mode 100644 vendor/github.com/godbus/dbus/v5/conn_other.go create mode 100644 vendor/github.com/godbus/dbus/v5/conn_unix.go create mode 100644 vendor/github.com/godbus/dbus/v5/conn_windows.go create mode 100644 vendor/github.com/godbus/dbus/v5/dbus.go create mode 100644 vendor/github.com/godbus/dbus/v5/decoder.go create mode 100644 vendor/github.com/godbus/dbus/v5/default_handler.go create mode 100644 vendor/github.com/godbus/dbus/v5/doc.go create mode 100644 vendor/github.com/godbus/dbus/v5/encoder.go create mode 100644 vendor/github.com/godbus/dbus/v5/escape.go create mode 100644 vendor/github.com/godbus/dbus/v5/export.go create mode 100644 vendor/github.com/godbus/dbus/v5/match.go create mode 100644 vendor/github.com/godbus/dbus/v5/message.go create mode 100644 vendor/github.com/godbus/dbus/v5/object.go create mode 100644 vendor/github.com/godbus/dbus/v5/sequence.go create mode 100644 vendor/github.com/godbus/dbus/v5/sequential_handler.go create mode 100644 vendor/github.com/godbus/dbus/v5/server_interfaces.go create mode 100644 vendor/github.com/godbus/dbus/v5/sig.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_darwin.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_generic.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_nonce_tcp.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_tcp.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_unix.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_unixcred_dragonfly.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_unixcred_freebsd.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_unixcred_linux.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_unixcred_netbsd.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_unixcred_openbsd.go create mode 100644 vendor/github.com/godbus/dbus/v5/transport_zos.go create mode 100644 vendor/github.com/godbus/dbus/v5/variant.go create mode 100644 vendor/github.com/godbus/dbus/v5/variant_lexer.go create mode 100644 vendor/github.com/godbus/dbus/v5/variant_parser.go create mode 100644 vendor/github.com/zalando/go-keyring/.catwatch.yml create mode 100644 vendor/github.com/zalando/go-keyring/.gitignore create mode 100644 vendor/github.com/zalando/go-keyring/.zappr.yml create mode 100644 vendor/github.com/zalando/go-keyring/CONTRIBUTING.md create mode 100644 vendor/github.com/zalando/go-keyring/LICENSE create mode 100644 vendor/github.com/zalando/go-keyring/MAINTAINERS create mode 100644 vendor/github.com/zalando/go-keyring/README.md create mode 100644 vendor/github.com/zalando/go-keyring/SECURITY.md create mode 100644 vendor/github.com/zalando/go-keyring/internal/shellescape/LICENSE create mode 100644 vendor/github.com/zalando/go-keyring/internal/shellescape/shellescape.go create mode 100644 vendor/github.com/zalando/go-keyring/keyring.go create mode 100644 vendor/github.com/zalando/go-keyring/keyring_darwin.go create mode 100644 vendor/github.com/zalando/go-keyring/keyring_fallback.go create mode 100644 vendor/github.com/zalando/go-keyring/keyring_mock.go create mode 100644 vendor/github.com/zalando/go-keyring/keyring_unix.go create mode 100644 vendor/github.com/zalando/go-keyring/keyring_windows.go create mode 100644 vendor/github.com/zalando/go-keyring/secret_service/secret_service.go diff --git a/README.md b/README.md index 4cd73ae..14787f9 100644 --- a/README.md +++ b/README.md @@ -117,27 +117,30 @@ Because Cursor does not publish a standalone usage CLI contract, usage is a read-only best-effort integration and reports an explicit error when the login or quota response is unavailable. -## Gemini subscription (Antigravity CLI) +## Gemini subscription -Sign in once with the official Antigravity CLI (`agy`) using the Google account -that owns the Gemini subscription, then run it through Prism: +Sign in to Antigravity with each Google subscription account and import the +active login into Prism. Repeat the first two commands for every account, then +run the official Gemini CLI through the shared pool: ```sh -agy +agy -p /usage --output-format json +prism gemini auth import +prism gemini auth list prism gemini usage prism gemini -p 'Reply with exactly GEMINI_OK.' ``` -`prism gemini` uses the signed-in `agy` profile directly. Prism never reads or -passes `GEMINI_API_KEY`/`GOOGLE_API_KEY`, and AI Studio API-key authentication is -intentionally unsupported because it can incur usage-based charges. -Before every Gemini run and usage check, Prism also forces -`userSettings.useG1Credits: false` in Antigravity's shared configuration so -purchased or promotional AI credits cannot be consumed after the subscription -quota is exhausted. +Prism rotates across registered subscription accounts unless `--account` +selects one: -`prism usage` and `prism gemini usage` show the Antigravity five-hour and weekly -subscription windows. Use `/usage` inside `agy` for the same live quota panel. +```sh +prism gemini --account work-admin -p 'Reply with exactly GEMINI_OK.' +``` + +AI Studio API keys are intentionally unsupported because they can incur +usage-based charges. `prism usage` and `prism gemini usage` show every +registered subscription account. The default model is `gemini-3.7-flash-low`. For harder software-engineering or multi-step tool-use tasks, select `gemini-3.1-pro-high` explicitly: @@ -146,6 +149,10 @@ multi-step tool-use tasks, select `gemini-3.1-pro-high` explicitly: prism gemini --model gemini-3.1-pro-high -p 'Review this repository.' ``` +`prism gemini auth login` remains available for organization-managed Gemini +Code Assist OAuth accounts. Imported Antigravity logins and Code Assist accounts +are stored separately and selected by the same rotation mechanism. + ## Anthropic Register each Claude subscription account separately and show its current quota: diff --git a/go.mod b/go.mod index 67a3568..bde32d4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,12 @@ go 1.24.0 require ( github.com/circlesac/credentials/go v1.2.2 + github.com/zalando/go-keyring v0.2.8 golang.org/x/term v0.34.0 ) -require golang.org/x/sys v0.35.0 // indirect +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum index 3d2f79d..ba71521 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,22 @@ github.com/circlesac/credentials/go v1.2.2 h1:sDEjuf0s8y9F37VCkweHC2J8BHDDQsHa59E9XGz0b9c= github.com/circlesac/credentials/go v1.2.2/go.mod h1:AfWGehoQtkKnIsAp5OrVV0jXes40dCDEjvfETadeYUQ= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/gemini.go b/internal/cli/gemini.go index fe7051f..a90020f 100644 --- a/internal/cli/gemini.go +++ b/internal/cli/gemini.go @@ -6,7 +6,6 @@ import ( "crypto/subtle" "encoding/base64" "encoding/hex" - "encoding/json" "errors" "fmt" "io" @@ -29,7 +28,6 @@ const defaultGeminiModel = "gemini-3.7-flash-low" type geminiCLIExecutable struct { path string prefix []string - direct bool } type geminiBridge struct { @@ -46,7 +44,7 @@ func isGeminiCLIInvocation(args []string) bool { return true } switch args[0] { - case "auth", "login", "add", "list", "remove": + case "auth", "login", "add", "list", "remove", "usage": return false default: return true @@ -54,24 +52,15 @@ func isGeminiCLIInvocation(args []string) bool { } func runGeminiCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { - if len(args) > 0 && args[0] == "usage" { - if len(args) != 1 { - return errors.New("usage: prism gemini usage") - } - return runGeminiUsage(ctx, stdout, stderr) - } if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") { printGeminiHelp(stdout) return nil } - if executable, executableErr := findGeminiCLIExecutable(); executableErr == nil && executable.direct { - return runAntigravity(ctx, executable, withDefaultGeminiModel(args), os.Stdin, stdout, stderr) - } - account, passthrough, err := parseGeminiOptions(args) + options, account, passthrough, err := parseGeminiOptions(args) if err != nil { return err } - client, err := prismClient(ctx, commonOptions{}) + client, err := prismClient(ctx, options) if err != nil { return err } @@ -103,7 +92,7 @@ func selectGeminiAccount(selector string, accounts []api.Credential) (string, er return matches[0], nil } if len(accounts) == 0 { - return "", errors.New("no Gemini subscription accounts are registered; run 'prism gemini auth login'") + return "", errors.New("no Gemini subscription accounts are registered; run 'prism gemini auth import'") } return rotateProviderAccount("gemini", accounts) } @@ -122,9 +111,6 @@ func runGemini( if err != nil { return err } - if executable.direct { - return runAntigravity(ctx, executable, args, stdin, stdout, stderr) - } bridge, err := startGeminiBridge(prismURL, prismCredential, account, stderr) if err != nil { @@ -157,100 +143,7 @@ func runGemini( } return nil } - -func runAntigravity(ctx context.Context, executable geminiCLIExecutable, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { - if err := disableAntigravityCreditOverages(); err != nil { - return err - } - commandArgs := append(append([]string{}, executable.prefix...), args...) - command := exec.CommandContext(ctx, executable.path, commandArgs...) - command.Stdin = stdin - command.Stdout = stdout - command.Stderr = stderr - command.Env = subscriptionGeminiEnvironment(os.Environ()) - if err := command.Run(); err != nil { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - return fmt.Errorf("Antigravity CLI exited with status %d", exitError.ExitCode()) - } - return fmt.Errorf("could not run Antigravity CLI: %w", err) - } - return nil -} - -var antigravityConfigPath = defaultAntigravityConfigPath - -func defaultAntigravityConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".gemini", "config", "config.json") -} - -func disableAntigravityCreditOverages() error { - path := antigravityConfigPath() - if path == "" { - return errors.New("could not locate Antigravity shared settings") - } - settings := map[string]any{} - contents, err := os.ReadFile(path) - if err == nil { - if len(strings.TrimSpace(string(contents))) != 0 { - if err := json.Unmarshal(contents, &settings); err != nil { - return errors.New("Antigravity shared settings are invalid; fix config.json before using Prism Gemini") - } - } - } else if !errors.Is(err, os.ErrNotExist) { - return errors.New("could not read Antigravity shared settings") - } - userSettings, ok := settings["userSettings"].(map[string]any) - if !ok { - if settings["userSettings"] != nil { - return errors.New("Antigravity shared userSettings are invalid; fix config.json before using Prism Gemini") - } - userSettings = map[string]any{} - settings["userSettings"] = userSettings - } - if value, ok := userSettings["useG1Credits"].(bool); ok && !value { - return nil - } - userSettings["useG1Credits"] = false - encoded, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return errors.New("could not encode Antigravity CLI settings") - } - encoded = append(encoded, '\n') - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return errors.New("could not create Antigravity shared settings directory") - } - temporary, err := os.CreateTemp(filepath.Dir(path), ".settings-*") - if err != nil { - return errors.New("could not write Antigravity CLI settings") - } - temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) - if err := temporary.Chmod(0o600); err != nil { - temporary.Close() - return errors.New("could not protect Antigravity CLI settings") - } - if _, err := temporary.Write(encoded); err != nil { - temporary.Close() - return errors.New("could not write Antigravity CLI settings") - } - if err := temporary.Close(); err != nil { - return errors.New("could not close Antigravity CLI settings") - } - if err := os.Rename(temporaryPath, path); err != nil { - return errors.New("could not activate Antigravity shared settings") - } - return nil -} - func findGeminiCLI() (geminiCLIExecutable, error) { - if path, err := exec.LookPath("agy"); err == nil { - return geminiCLIExecutable{path: path, direct: true}, nil - } if path, err := exec.LookPath("gemini"); err == nil { return geminiCLIExecutable{path: path}, nil } @@ -259,26 +152,56 @@ func findGeminiCLI() (geminiCLIExecutable, error) { } return geminiCLIExecutable{}, errors.New("Gemini CLI is not installed and npx is not on PATH") } - -func subscriptionGeminiEnvironment(environment []string) []string { - filtered := make([]string, 0, len(environment)) - for _, entry := range environment { - name, _, _ := strings.Cut(entry, "=") - switch strings.ToUpper(name) { - case "GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_VERTEX_BASE_URL": - continue +func parseGeminiOptions(args []string) (commonOptions, string, []string, error) { + var options commonOptions + var account string + var passthrough []string + for index := 0; index < len(args); index++ { + argument := args[index] + switch { + case argument == "--": + return options, account, append(passthrough, args[index:]...), nil + case argument == "--profile": + if options.profileSet { + return commonOptions{}, "", nil, errors.New("--profile may be specified only once") + } + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" || args[index] == "--" { + return commonOptions{}, "", nil, errors.New("--profile requires a value") + } + options.profile = strings.TrimSpace(args[index]) + options.profileSet = true + case strings.HasPrefix(argument, "--profile="): + if options.profileSet { + return commonOptions{}, "", nil, errors.New("--profile may be specified only once") + } + options.profile = strings.TrimSpace(strings.TrimPrefix(argument, "--profile=")) + if options.profile == "" { + return commonOptions{}, "", nil, errors.New("--profile requires a value") + } + options.profileSet = true + case argument == "--account": + if account != "" { + return commonOptions{}, "", nil, errors.New("--account may be specified only once") + } + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" || args[index] == "--" { + return commonOptions{}, "", nil, errors.New("--account requires a value") + } + account = strings.TrimSpace(args[index]) + case strings.HasPrefix(argument, "--account="): + if account != "" { + return commonOptions{}, "", nil, errors.New("--account may be specified only once") + } + account = strings.TrimSpace(strings.TrimPrefix(argument, "--account=")) + if account == "" { + return commonOptions{}, "", nil, errors.New("--account requires a value") + } + default: + passthrough = append(passthrough, argument) } - filtered = append(filtered, entry) - } - return filtered -} - -func parseGeminiOptions(args []string) (string, []string, error) { - account, passthrough, err := parseClaudeOptions(args) - if err != nil { - return "", nil, err } - return account, passthrough, nil + return options, account, passthrough, nil } func withDefaultGeminiModel(args []string) []string { @@ -394,14 +317,14 @@ func geminiEnvironment(environment []string, baseURL string, customHeaders strin func printGeminiHelp(output io.Writer) { _, _ = fmt.Fprintln(output, `Usage: - prism gemini auth login|list|remove - prism gemini [--account ] [Gemini CLI arguments...] + prism gemini auth import|login|list|remove + prism gemini [--profile ] [--account ] [Gemini CLI arguments...] - Runs Antigravity CLI (agy) with the signed-in Google Gemini subscription. - AI Studio API keys are intentionally unsupported to prevent usage-based charges. - Prism forces useG1Credits=false before every run to prevent paid overages. - Use 'prism gemini usage' or 'prism usage' to show the subscription quota. - The default model is gemini-3.7-flash-low; use --model gemini-3.1-pro-high for +Runs the official Gemini CLI through Prism's registered subscription accounts. +Accounts rotate automatically unless --account selects one. AI Studio API keys +are intentionally unsupported to prevent usage-based charges. Use +'prism gemini usage' or 'prism usage' to show every registered account. +The default model is gemini-3.7-flash-low; use --model gemini-3.1-pro-high for hard software-engineering and multi-step tool-use work. Run 'gemini --help' for Gemini CLI options.`) } diff --git a/internal/cli/gemini_test.go b/internal/cli/gemini_test.go index 6dc5fef..e6202bd 100644 --- a/internal/cli/gemini_test.go +++ b/internal/cli/gemini_test.go @@ -3,7 +3,6 @@ package cli import ( "bytes" "context" - "encoding/json" "io" "net/http" "net/http/httptest" @@ -17,11 +16,11 @@ import ( "github.com/circlesac/prism-cli/internal/api" ) -func TestGeminiDefaultsTo37FlashAndPreservesExplicitModel(t *testing.T) { +func TestGeminiDefaultsTo37FlashLowAndPreservesExplicitModel(t *testing.T) { if got := withDefaultGeminiModel([]string{"-p", "hello"}); !reflect.DeepEqual(got, []string{"--model", "gemini-3.7-flash-low", "-p", "hello"}) { t.Fatalf("default args = %#v", got) } - for _, args := range [][]string{{"--model", "gemini-3.1-pro-preview", "-p", "hard"}, {"-m", "gemini-3.1-pro-preview"}, {"--model=gemini-3.1-pro-preview"}} { + for _, args := range [][]string{{"--model", "gemini-3.1-pro-high", "-p", "hard"}, {"-m", "gemini-3.1-pro-high"}, {"--model=gemini-3.1-pro-high"}} { if got := withDefaultGeminiModel(args); !reflect.DeepEqual(got, args) { t.Fatalf("explicit model args = %#v", got) } @@ -33,13 +32,31 @@ func TestGeminiHelpDocumentsOfficialCLIAccountsAndModels(t *testing.T) { if err := runGeminiCommand(context.Background(), []string{"--help"}, &output, io.Discard); err != nil { t.Fatal(err) } - for _, value := range []string{"Antigravity CLI", "subscription", "AI Studio API keys are intentionally unsupported", "gemini-3.7-flash-low", "gemini-3.1-pro-high"} { + for _, value := range []string{"official Gemini CLI", "auth import", "--account", "rotate automatically", "subscription", "AI Studio API keys", "gemini-3.7-flash-low", "gemini-3.1-pro-high"} { if !strings.Contains(output.String(), value) { t.Fatalf("help omitted %q: %s", value, output.String()) } } } +func TestFindGeminiCLIIgnoresAntigravityAndUsesGatewayCompatibleCLI(t *testing.T) { + directory := t.TempDir() + for _, name := range []string{"agy", "gemini"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", directory) + + executable, err := findGeminiCLI() + if err != nil { + t.Fatal(err) + } + if executable.path != filepath.Join(directory, "gemini") || len(executable.prefix) != 0 { + t.Fatalf("executable = %#v", executable) + } +} + func TestGeminiAccountSelectionUsesSubscriptionAccounts(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) accounts := []api.Credential{{ID: "oauth-1", Name: "personal"}, {ID: "oauth-2", Name: "work-admin"}} @@ -54,6 +71,36 @@ func TestGeminiAccountSelectionUsesSubscriptionAccounts(t *testing.T) { } } +func TestGeminiOptionsSelectProfileAndAccountWithoutPassingThemThrough(t *testing.T) { + options, account, passthrough, err := parseGeminiOptions([]string{ + "--profile", "dev", "--account=work-admin", "-p", "hello", + }) + if err != nil { + t.Fatal(err) + } + if !options.profileSet || options.profile != "dev" || account != "work-admin" { + t.Fatalf("options = %#v, account = %q", options, account) + } + if !reflect.DeepEqual(passthrough, []string{"-p", "hello"}) { + t.Fatalf("passthrough = %#v", passthrough) + } +} + +func TestGeminiOptionsPreserveArgumentsAfterSeparator(t *testing.T) { + options, account, passthrough, err := parseGeminiOptions([]string{ + "--profile=dev", "--account", "personal", "--", "--account", "prompt-value", + }) + if err != nil { + t.Fatal(err) + } + if options.profile != "dev" || account != "personal" { + t.Fatalf("options = %#v, account = %q", options, account) + } + if !reflect.DeepEqual(passthrough, []string{"--", "--account", "prompt-value"}) { + t.Fatalf("passthrough = %#v", passthrough) + } +} + func TestGeminiBridgeAuthenticatesLocallyAndSelectsAccount(t *testing.T) { var requests atomic.Int32 upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { @@ -127,70 +174,3 @@ func TestRunGeminiUsesOfficialCLIWithGatewayEnvironment(t *testing.T) { } } } - -func TestAntigravityUsageParsesSubscriptionWindowsAndScrubsAPIKeys(t *testing.T) { - original := findGeminiCLIExecutable - originalConfig := antigravityConfigPath - defer func() { - findGeminiCLIExecutable = original - antigravityConfigPath = originalConfig - }() - directory := t.TempDir() - antigravityConfigPath = func() string { return filepath.Join(directory, "config.json") } - executable := filepath.Join(directory, "agy") - if err := os.WriteFile(executable, []byte("#!/bin/sh\nif [ -n \"$GEMINI_API_KEY$GOOGLE_API_KEY$GOOGLE_GEMINI_BASE_URL\" ]; then exit 3; fi\nprintf '%s' '{\"status\":\"SUCCESS\",\"command\":{\"data\":{\"groups\":[{\"name\":\"Gemini Models\",\"buckets\":[{\"name\":\"Five Hour Limit Remaining\",\"window\":\"5h\",\"remaining_fraction\":0.75,\"reset_time\":\"2026-08-25T13:00:00Z\"}]}]}}}'\n"), 0o755); err != nil { - t.Fatal(err) - } - findGeminiCLIExecutable = func() (geminiCLIExecutable, error) { - return geminiCLIExecutable{path: executable, direct: true}, nil - } - t.Setenv("GEMINI_API_KEY", "must-not-pass") - t.Setenv("GOOGLE_API_KEY", "must-not-pass") - t.Setenv("GOOGLE_GEMINI_BASE_URL", "must-not-pass") - usage, err := fetchAntigravityUsage(context.Background()) - if err != nil { - t.Fatal(err) - } - if usage.Provider != "gemini" || len(usage.Accounts) != 1 || len(usage.Accounts[0].Limits) != 1 { - t.Fatalf("usage = %#v", usage) - } - limit := usage.Accounts[0].Limits[0] - if limit.RemainingPercent != 75 || limit.UsedPercent != 25 || limit.Window != "5h" || limit.WindowSeconds == nil || *limit.WindowSeconds != 18000 { - t.Fatalf("limit = %#v", limit) - } -} - -func TestRunAntigravityDisablesCreditOveragesBeforeStartingCLI(t *testing.T) { - original := antigravityConfigPath - defer func() { antigravityConfigPath = original }() - directory := t.TempDir() - path := filepath.Join(directory, "config.json") - antigravityConfigPath = func() string { return path } - if err := os.WriteFile(path, []byte("{\"userSettings\":{\"remoteControlHostname\":\"example-host\",\"useG1Credits\":true}}\n"), 0o600); err != nil { - t.Fatal(err) - } - executable := filepath.Join(directory, "agy") - if err := os.WriteFile(executable, []byte("#!/bin/sh\ngrep -q '\"useG1Credits\": false' \"$PRISM_TEST_SETTINGS\" || exit 9\nprintf CREDIT_GUARD_OK\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PRISM_TEST_SETTINGS", path) - var output bytes.Buffer - if err := runAntigravity(context.Background(), geminiCLIExecutable{path: executable, direct: true}, nil, strings.NewReader(""), &output, io.Discard); err != nil { - t.Fatal(err) - } - if output.String() != "CREDIT_GUARD_OK" { - t.Fatalf("output = %q", output.String()) - } - var settings map[string]any - contents, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(contents, &settings); err != nil { - t.Fatal(err) - } - userSettings, ok := settings["userSettings"].(map[string]any) - if !ok || userSettings["useG1Credits"] != false || userSettings["remoteControlHostname"] != "example-host" { - t.Fatalf("settings = %#v", settings) - } -} diff --git a/internal/cli/gemini_usage.go b/internal/cli/gemini_usage.go index 7ae7dd4..faf27bf 100644 --- a/internal/cli/gemini_usage.go +++ b/internal/cli/gemini_usage.go @@ -1,120 +1,15 @@ package cli import ( - "bytes" "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "strings" "github.com/circlesac/prism-cli/internal/api" ) -type antigravityUsageEnvelope struct { - Status string `json:"status"` - Error string `json:"error"` - Command struct { - Data struct { - Groups []struct { - Name string `json:"name"` - Buckets []struct { - Name string `json:"name"` - Window string `json:"window"` - RemainingFraction float64 `json:"remaining_fraction"` - ResetTime string `json:"reset_time"` - } `json:"buckets"` - } `json:"groups"` - } `json:"data"` - } `json:"command"` -} - -var fetchGeminiUsage = fetchAntigravityUsage - -func runGeminiUsage(ctx context.Context, stdout io.Writer, stderr io.Writer) error { - usage, err := fetchGeminiUsage(ctx) +var fetchGeminiUsage = func(ctx context.Context, options commonOptions) (api.ProviderUsage, error) { + client, err := prismClient(ctx, options) if err != nil { - return err - } - printUsage(stdout, usage) - return nil -} - -func fetchAntigravityUsage(ctx context.Context) (api.ProviderUsage, error) { - if err := disableAntigravityCreditOverages(); err != nil { return api.ProviderUsage{}, err } - executable, err := findGeminiCLIExecutable() - if err != nil { - return api.ProviderUsage{}, err - } - if !executable.direct { - return api.ProviderUsage{}, errors.New("Antigravity CLI (agy) is not installed; Gemini subscription usage requires agy") - } - commandArgs := append(append([]string{}, executable.prefix...), "-p", "/usage", "--output-format", "json") - command := exec.CommandContext(ctx, executable.path, commandArgs...) - command.Env = subscriptionGeminiEnvironment(os.Environ()) - var stdout bytes.Buffer - var stderr bytes.Buffer - command.Stdout = &stdout - command.Stderr = &stderr - if err := command.Run(); err != nil { - message := strings.TrimSpace(stderr.String()) - if message == "" { - message = err.Error() - } - return api.ProviderUsage{}, fmt.Errorf("Antigravity usage unavailable: %s", message) - } - var envelope antigravityUsageEnvelope - if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { - return api.ProviderUsage{}, errors.New("Antigravity returned invalid usage JSON") - } - if envelope.Status != "" && envelope.Status != "SUCCESS" { - if envelope.Error == "" { - envelope.Error = "Antigravity usage request failed" - } - return api.ProviderUsage{}, errors.New(envelope.Error) - } - accounts := []api.UsageAccount{{Name: "Google Gemini subscription", Status: "fresh"}} - plan := "Google Gemini subscription" - accounts[0].Plan = &plan - for _, group := range envelope.Command.Data.Groups { - for _, bucket := range group.Buckets { - remaining := bucket.RemainingFraction * 100 - if remaining < 0 { - remaining = 0 - } - if remaining > 100 { - remaining = 100 - } - used := 100 - remaining - limit := api.UsageLimit{ - Name: group.Name + " — " + bucket.Name, - Window: bucket.Window, - UsedPercent: used, - RemainingPercent: remaining, - LimitReached: remaining <= 0, - } - if bucket.ResetTime != "" { - reset := bucket.ResetTime - limit.ResetAt = &reset - } - switch bucket.Window { - case "5h": - seconds := 5 * 60 * 60 - limit.WindowSeconds = &seconds - case "weekly": - seconds := 7 * 24 * 60 * 60 - limit.WindowSeconds = &seconds - } - accounts[0].Limits = append(accounts[0].Limits, limit) - } - } - if len(accounts[0].Limits) == 0 { - return api.ProviderUsage{}, errors.New("Antigravity usage returned no quota windows") - } - return api.ProviderUsage{Provider: "gemini", Accounts: accounts}, nil + return client.Usage(ctx, "gemini") } diff --git a/internal/cli/run.go b/internal/cli/run.go index 5b27d9e..590d953 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -103,7 +103,7 @@ func Run( commandArgs := args[2:] if command == "auth" { if len(args) < 3 { - return errors.New("unknown provider auth command; use login/add, list, or remove") + return errors.New("unknown provider auth command; use login/add/import, list, or remove") } command = args[2] commandArgs = args[3:] @@ -128,7 +128,7 @@ func Run( if providerName == "opencode-go" { usage, err = fetchOpenCodeGoUsage(ctx) } else if providerName == "gemini" { - usage, err = fetchGeminiUsage(ctx) + usage, err = fetchGeminiUsage(ctx, options) } else if providerName == "anthropic" { usage, err = fetchAnthropicUsage(ctx, options) } else if providerName == "copilot" { @@ -151,6 +151,17 @@ func Run( switch command { case "login": return loginProvider(ctx, providerName, client, stdout) + case "import": + fmt.Fprintln(stdout, "Importing the active Antigravity subscription login...") + bundle, err := (gemini.AntigravityImport{}).Import(ctx) + if err != nil { + return err + } + saved, err := client.Save(ctx, "gemini", "", bundle) + if err != nil { + return err + } + fmt.Fprintf(stdout, "Saved Gemini subscription %s (%s).\n", saved.Name, saved.ID) case "add": bundle, err := readProviderCredential(providerName, options, os.Stdin, stderr) if err != nil { @@ -238,7 +249,7 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro cursorResults <- usageResult{usage: usage, err: fetchErr} }() go func() { - usage, fetchErr := fetchGeminiUsage(ctx) + usage, fetchErr := fetchGeminiUsage(ctx, options) geminiResults <- usageResult{usage: usage, err: fetchErr} }() @@ -297,6 +308,16 @@ func validateCommand(provider string, command string, positionals []string, opti if options.name != "" || options.providerAccountID != "" || options.ownerID != "" { return errors.New("OAuth account identity is determined from the provider callback") } + case "import": + if provider != "gemini" { + return fmt.Errorf("%s does not support 'auth import'", provider) + } + if len(positionals) != 0 { + return fmt.Errorf("unexpected argument %q", positionals[0]) + } + if options.name != "" || options.providerAccountID != "" || options.ownerID != "" { + return errors.New("imported account identity is determined from the Antigravity login") + } case "add": if provider == "chatgpt" || provider == "copilot" || provider == "gemini" { return fmt.Errorf("%s uses 'auth login', not 'auth add'", provider) @@ -316,7 +337,7 @@ func validateCommand(provider string, command string, positionals []string, opti return fmt.Errorf("usage: prism %s auth remove [--profile ]", provider) } default: - return errors.New("unknown provider auth command; use login/add, list, or remove") + return errors.New("unknown provider auth command; use login/add/import, list, or remove") } return nil } @@ -808,6 +829,7 @@ Usage: prism anthropic auth login [--profile ] prism claude login [--profile ] prism copilot auth login [--profile ] + prism gemini auth import [--profile ] prism gemini auth login [--profile ] prism auth add [--name ] [provider options] prism auth list [--profile ] @@ -823,6 +845,17 @@ Run 'crcl login' before using Prism.`) } func printProviderAuthHelp(output io.Writer, provider string) { + if provider == "gemini" { + fmt.Fprintln(output, `Usage: + prism gemini auth import [--profile ] + prism gemini auth login [--profile ] + prism gemini auth list [--profile ] + prism gemini auth remove [--profile ] + +import copies the active Antigravity subscription login into Prism. login adds +a Gemini Code Assist OAuth account.`) + return + } if provider == "chatgpt" || provider == "anthropic" || provider == "copilot" || provider == "gemini" { fmt.Fprintf(output, `Usage: prism %s auth login [--profile ] diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 31860a8..48d15b3 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -21,7 +21,7 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) { t.Fatal(err) } output := stdout.String() - for _, command := range []string{"prism exec", "prism claude", "prism codex", "prism cursor", "prism usage", "chatgpt usage", "anthropic auth login", "opencode-go usage", "auth login", "auth list", "auth remove"} { + for _, command := range []string{"prism exec", "prism claude", "prism codex", "prism cursor", "prism usage", "chatgpt usage", "anthropic auth login", "opencode-go usage", "auth import", "auth login", "auth list", "auth remove"} { if !strings.Contains(output, command) { t.Fatalf("help did not contain %q", command) } @@ -91,7 +91,10 @@ func TestCombinedUsageShowsEveryProvider(t *testing.T) { Name: "cursor@example.com", Limits: []api.UsageLimit{{Name: "Cursor Models", Window: "monthly", UsedPercent: 5, RemainingPercent: 95}}, }}}, nil } - fetchGeminiUsage = func(context.Context) (api.ProviderUsage, error) { + fetchGeminiUsage = func(_ context.Context, options commonOptions) (api.ProviderUsage, error) { + if options.profile != "work-admin" || !options.profileSet { + t.Fatalf("Gemini options = %+v", options) + } return api.ProviderUsage{Provider: "gemini", Accounts: []api.UsageAccount{{Name: "Google Gemini subscription", Limits: []api.UsageLimit{{Name: "Gemini Models — Five Hour Limit Remaining", Window: "5h", RemainingPercent: 95}}}}}, nil } @@ -141,7 +144,7 @@ func TestCombinedUsageKeepsPartialResults(t *testing.T) { fetchCursorUsage = func(context.Context, prismcursor.UsageOptions) (api.ProviderUsage, error) { return api.ProviderUsage{}, errors.New("Cursor login unavailable") } - fetchGeminiUsage = func(context.Context) (api.ProviderUsage, error) { + fetchGeminiUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { return api.ProviderUsage{}, errors.New("Gemini login unavailable") } @@ -185,7 +188,7 @@ func TestCombinedUsageFailsOnlyWhenEveryProviderFails(t *testing.T) { fetchCursorUsage = func(context.Context, prismcursor.UsageOptions) (api.ProviderUsage, error) { return api.ProviderUsage{}, errors.New("unavailable") } - fetchGeminiUsage = func(context.Context) (api.ProviderUsage, error) { + fetchGeminiUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { return api.ProviderUsage{}, errors.New("unavailable") } diff --git a/internal/gemini/antigravity.go b/internal/gemini/antigravity.go new file mode 100644 index 0000000..6218dbe --- /dev/null +++ b/internal/gemini/antigravity.go @@ -0,0 +1,128 @@ +package gemini + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/zalando/go-keyring" +) + +const antigravityKeyringPrefix = "go-keyring-base64:" + +type AntigravityImport struct { + Client *http.Client + ReadSecret func() (string, error) + ReadVersion func(context.Context) (string, error) + Now func() time.Time +} + +type antigravityStoredLogin struct { + AuthMethod string `json:"auth_method"` + Token struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry time.Time `json:"expiry"` + } `json:"token"` +} + +func (i AntigravityImport) Import(ctx context.Context) (Bundle, error) { + readSecret := i.ReadSecret + if readSecret == nil { + readSecret = func() (string, error) { return keyring.Get("gemini", "antigravity") } + } + encoded, err := readSecret() + if err != nil { + return Bundle{}, errors.New("Antigravity login was not found; sign in with 'agy' first") + } + login, err := decodeAntigravityLogin(encoded) + if err != nil { + return Bundle{}, err + } + now := time.Now + if i.Now != nil { + now = i.Now + } + if !login.Token.Expiry.After(now().Add(5 * time.Minute)) { + return Bundle{}, errors.New("Antigravity login is stale; run 'agy -p /usage --output-format json' and import it again") + } + readVersion := i.ReadVersion + if readVersion == nil { + readVersion = func(ctx context.Context) (string, error) { + output, err := exec.CommandContext(ctx, "agy", "--version").Output() + return strings.TrimSpace(string(output)), err + } + } + version, err := readVersion(ctx) + if err != nil || version == "" || strings.ContainsAny(version, " /()\t\r\n") { + return Bundle{}, errors.New("could not read the Antigravity CLI version; run 'agy --version' and try again") + } + client := i.Client + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + projectID, err := antigravityProject(ctx, client, login.Token.AccessToken) + if err != nil { + return Bundle{}, err + } + email, name, err := profile(ctx, client, login.Token.AccessToken) + if err != nil { + return Bundle{}, err + } + alias := email + if alias == "" { + alias = name + } + return Bundle{ + AccessToken: login.Token.AccessToken, + RefreshToken: login.Token.RefreshToken, + ProjectID: projectID, + Email: email, + Alias: alias, + ExpiresAt: login.Token.Expiry.UnixMilli(), + AuthMethod: "antigravity", + UserAgent: "antigravity/cli/" + version, + }, nil +} + +func decodeAntigravityLogin(value string) (antigravityStoredLogin, error) { + decoded := []byte(strings.TrimSpace(value)) + if encoded, ok := strings.CutPrefix(string(decoded), antigravityKeyringPrefix); ok { + var err error + decoded, err = base64.StdEncoding.DecodeString(encoded) + if err != nil { + return antigravityStoredLogin{}, errors.New("Antigravity login is corrupt") + } + } + var login antigravityStoredLogin + if err := json.Unmarshal(decoded, &login); err != nil { + return antigravityStoredLogin{}, errors.New("Antigravity login is corrupt") + } + if login.AuthMethod != "consumer" || login.Token.AccessToken == "" || login.Token.RefreshToken == "" || login.Token.Expiry.IsZero() { + return antigravityStoredLogin{}, errors.New("Antigravity consumer login is incomplete") + } + return login, nil +} + +func antigravityProject(ctx context.Context, client *http.Client, accessToken string) (string, error) { + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", strings.NewReader(`{"metadata":{"ideType":"ANTIGRAVITY"}}`)) + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "antigravity/cli/prism") + var response struct { + CloudAICompanionProject string `json:"cloudaicompanionProject"` + } + if err := doJSON(client, request, &response); err != nil { + return "", fmt.Errorf("load Antigravity subscription: %w", err) + } + if response.CloudAICompanionProject == "" { + return "", errors.New("Antigravity subscription did not return a Code Assist project") + } + return response.CloudAICompanionProject, nil +} diff --git a/internal/gemini/antigravity_test.go b/internal/gemini/antigravity_test.go new file mode 100644 index 0000000..5933c14 --- /dev/null +++ b/internal/gemini/antigravity_test.go @@ -0,0 +1,75 @@ +package gemini + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestImportAntigravityLogin(t *testing.T) { + expires := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + secret := antigravityKeyringPrefix + base64.StdEncoding.EncodeToString([]byte(`{ + "auth_method":"consumer", + "token":{"access_token":"provider-access","refresh_token":"provider-refresh","expiry":"2026-09-01T00:00:00Z"} + }`)) + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + var body string + switch request.URL.String() { + case "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist": + if request.Header.Get("Authorization") != "Bearer provider-access" || request.Header.Get("User-Agent") != "antigravity/cli/prism" { + t.Fatalf("subscription headers = %#v", request.Header) + } + body = `{"cloudaicompanionProject":"project-123"}` + case "https://www.googleapis.com/oauth2/v3/userinfo": + body = `{"sub":"subject-123","email":"person@example.com","name":"Person"}` + default: + t.Fatalf("unexpected request: %s", request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + + bundle, err := (AntigravityImport{ + Client: client, + ReadSecret: func() (string, error) { return secret, nil }, + ReadVersion: func(context.Context) (string, error) { return "1.1.22", nil }, + Now: func() time.Time { return expires.Add(-time.Hour) }, + }).Import(context.Background()) + if err != nil { + t.Fatal(err) + } + if bundle.ProjectID != "project-123" || bundle.Alias != "person@example.com" || bundle.AuthMethod != "antigravity" || bundle.UserAgent != "antigravity/cli/1.1.22" || bundle.RefreshToken != "provider-refresh" { + t.Fatalf("bundle = %#v", bundle) + } +} + +func TestDecodeAntigravityLoginAcceptsKeyringDecodedJSON(t *testing.T) { + login, err := decodeAntigravityLogin(`{ + "auth_method":"consumer", + "token":{"access_token":"provider-access","refresh_token":"provider-refresh","expiry":"2026-09-01T00:00:00Z"} + }`) + if err != nil { + t.Fatal(err) + } + if login.Token.AccessToken != "provider-access" { + t.Fatalf("access token = %q", login.Token.AccessToken) + } +} + +func TestImportAntigravityRejectsStaleLogin(t *testing.T) { + secret := antigravityKeyringPrefix + base64.StdEncoding.EncodeToString([]byte(`{ + "auth_method":"consumer", + "token":{"access_token":"provider-access","refresh_token":"provider-refresh","expiry":"2026-08-01T00:00:00Z"} + }`)) + _, err := (AntigravityImport{ + ReadSecret: func() (string, error) { return secret, nil }, + ReadVersion: func(context.Context) (string, error) { return "1.1.22", nil }, + Now: func() time.Time { return time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC) }, + }).Import(context.Background()) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/gemini/oauth.go b/internal/gemini/oauth.go index fec03bf..0cc0084 100644 --- a/internal/gemini/oauth.go +++ b/internal/gemini/oauth.go @@ -29,6 +29,8 @@ type Bundle struct { Alias string `json:"alias,omitempty"` AccountID string `json:"account_id,omitempty"` ExpiresAt int64 `json:"expires_at"` + AuthMethod string `json:"auth_method,omitempty"` + UserAgent string `json:"antigravity_user_agent,omitempty"` } type tokenResponse struct { @@ -173,6 +175,7 @@ func (o OAuth) Login(ctx context.Context) (Bundle, error) { Alias: alias, AccountID: projectID, ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second).UnixMilli(), + AuthMethod: "code-assist", }, nil } diff --git a/vendor/github.com/danieljoos/wincred/.gitattributes b/vendor/github.com/danieljoos/wincred/.gitattributes new file mode 100644 index 0000000..d207b18 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/.gitattributes @@ -0,0 +1 @@ +*.go text eol=lf diff --git a/vendor/github.com/danieljoos/wincred/.gitignore b/vendor/github.com/danieljoos/wincred/.gitignore new file mode 100644 index 0000000..6142c06 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/.gitignore @@ -0,0 +1,25 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test + +coverage.txt diff --git a/vendor/github.com/danieljoos/wincred/LICENSE b/vendor/github.com/danieljoos/wincred/LICENSE new file mode 100644 index 0000000..2f436f1 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Daniel Joos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vendor/github.com/danieljoos/wincred/README.md b/vendor/github.com/danieljoos/wincred/README.md new file mode 100644 index 0000000..8a879b0 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/README.md @@ -0,0 +1,145 @@ +wincred +======= + +Go wrapper around the Windows Credential Manager API functions. + +[![GitHub release](https://img.shields.io/github/release/danieljoos/wincred.svg?style=flat-square)](https://github.com/danieljoos/wincred/releases/latest) +[![Test Status](https://img.shields.io/github/actions/workflow/status/danieljoos/wincred/test.yml?label=test&logo=github&style=flat-square)](https://github.com/danieljoos/wincred/actions?query=workflow%3Atest) +[![Go Report Card](https://goreportcard.com/badge/github.com/danieljoos/wincred)](https://goreportcard.com/report/github.com/danieljoos/wincred) +[![Codecov](https://img.shields.io/codecov/c/github/danieljoos/wincred?logo=codecov&style=flat-square)](https://codecov.io/gh/danieljoos/wincred) +[![PkgGoDev](https://img.shields.io/badge/go.dev-docs-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/danieljoos/wincred) + +Installation +------------ + +```Go +go get github.com/danieljoos/wincred +``` + + +Usage +----- + +See the following examples: + +### Create and store a new generic credential object +```Go +package main + +import ( + "fmt" + "github.com/danieljoos/wincred" +) + +func main() { + cred := wincred.NewGenericCredential("myGoApplication") + cred.CredentialBlob = []byte("my secret") + err := cred.Write() + + if err != nil { + fmt.Println(err) + } +} +``` + +### Retrieve a credential object +```Go +package main + +import ( + "fmt" + "github.com/danieljoos/wincred" +) + +func main() { + cred, err := wincred.GetGenericCredential("myGoApplication") + if err == nil { + fmt.Println(string(cred.CredentialBlob)) + } +} +``` + +### Remove a credential object +```Go +package main + +import ( + "fmt" + "github.com/danieljoos/wincred" +) + +func main() { + cred, err := wincred.GetGenericCredential("myGoApplication") + if err != nil { + fmt.Println(err) + return + } + cred.Delete() +} +``` + +### List all available credentials +```Go +package main + +import ( + "fmt" + "github.com/danieljoos/wincred" +) + +func main() { + creds, err := wincred.List() + if err != nil { + fmt.Println(err) + return + } + for i := range(creds) { + fmt.Println(creds[i].TargetName) + } +} +``` + +Hints +----- + +### Encoding + +The credential objects simply store byte arrays without specific meaning or encoding. +For sharing between different applications, it might make sense to apply an explicit string encoding - for example **UTF-16 LE** (used nearly everywhere in the Win32 API). + +```Go +package main + +import ( + "fmt" + "os" + + "github.com/danieljoos/wincred" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +func main() { + cred := wincred.NewGenericCredential("myGoApplication") + + encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + blob, _, err := transform.Bytes(encoder, []byte("mysecret")) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + cred.CredentialBlob = blob + err = cred.Write() + + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +``` + +### Limitations + +The size of a credential blob is limited to **2560 Bytes** by the Windows API. diff --git a/vendor/github.com/danieljoos/wincred/conversion.go b/vendor/github.com/danieljoos/wincred/conversion.go new file mode 100644 index 0000000..859aa04 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/conversion.go @@ -0,0 +1,110 @@ +// +build windows + +package wincred + +import ( + "encoding/binary" + "reflect" + "time" + "unsafe" + + syscall "golang.org/x/sys/windows" +) + +// utf16ToByte creates a byte array from a given UTF 16 char array. +func utf16ToByte(wstr []uint16) (result []byte) { + result = make([]byte, len(wstr)*2) + for i := range wstr { + binary.LittleEndian.PutUint16(result[(i*2):(i*2)+2], wstr[i]) + } + return +} + +// utf16FromString creates a UTF16 char array from a string. +func utf16FromString(str string) []uint16 { + res, err := syscall.UTF16FromString(str) + if err != nil { + return []uint16{} + } + return res +} + +// goBytes copies the given C byte array to a Go byte array (see `C.GoBytes`). +// This function avoids having cgo as dependency. +func goBytes(src *byte, len uint32) []byte { + if src == nil || len == 0 { + return []byte{} + } + rv := make([]byte, len) + copy(rv, *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ + Data: uintptr(unsafe.Pointer(src)), + Len: int(len), + Cap: int(len), + }))) + return rv +} + +// Convert the given CREDENTIAL struct to a more usable structure +func sysToCredential(cred *sysCREDENTIAL) (result *Credential) { + if cred == nil { + return nil + } + result = new(Credential) + result.Comment = syscall.UTF16PtrToString(cred.Comment) + result.TargetName = syscall.UTF16PtrToString(cred.TargetName) + result.TargetAlias = syscall.UTF16PtrToString(cred.TargetAlias) + result.UserName = syscall.UTF16PtrToString(cred.UserName) + result.LastWritten = time.Unix(0, cred.LastWritten.Nanoseconds()) + result.Persist = CredentialPersistence(cred.Persist) + result.CredentialBlob = goBytes(cred.CredentialBlob, cred.CredentialBlobSize) + result.Attributes = make([]CredentialAttribute, cred.AttributeCount) + attrSlice := *(*[]sysCREDENTIAL_ATTRIBUTE)(unsafe.Pointer(&reflect.SliceHeader{ + Data: uintptr(unsafe.Pointer(cred.Attributes)), + Len: int(cred.AttributeCount), + Cap: int(cred.AttributeCount), + })) + for i, attr := range attrSlice { + resultAttr := &result.Attributes[i] + resultAttr.Keyword = syscall.UTF16PtrToString(attr.Keyword) + resultAttr.Value = goBytes(attr.Value, attr.ValueSize) + } + return result +} + +// Convert the given Credential object back to a CREDENTIAL struct, which can be used for calling the +// Windows APIs +func sysFromCredential(cred *Credential) (result *sysCREDENTIAL) { + if cred == nil { + return nil + } + result = new(sysCREDENTIAL) + result.Flags = 0 + result.Type = 0 + result.TargetName, _ = syscall.UTF16PtrFromString(cred.TargetName) + result.Comment, _ = syscall.UTF16PtrFromString(cred.Comment) + result.LastWritten = syscall.NsecToFiletime(cred.LastWritten.UnixNano()) + result.CredentialBlobSize = uint32(len(cred.CredentialBlob)) + if len(cred.CredentialBlob) > 0 { + result.CredentialBlob = &cred.CredentialBlob[0] + } + result.Persist = uint32(cred.Persist) + result.AttributeCount = uint32(len(cred.Attributes)) + attributes := make([]sysCREDENTIAL_ATTRIBUTE, len(cred.Attributes)) + if len(attributes) > 0 { + result.Attributes = &attributes[0] + } + for i := range cred.Attributes { + inAttr := &cred.Attributes[i] + outAttr := &attributes[i] + outAttr.Keyword, _ = syscall.UTF16PtrFromString(inAttr.Keyword) + outAttr.Flags = 0 + outAttr.ValueSize = uint32(len(inAttr.Value)) + if len(inAttr.Value) > 0 { + outAttr.Value = &inAttr.Value[0] + } + } + result.TargetAlias, _ = syscall.UTF16PtrFromString(cred.TargetAlias) + result.UserName, _ = syscall.UTF16PtrFromString(cred.UserName) + + return +} diff --git a/vendor/github.com/danieljoos/wincred/conversion_unsupported.go b/vendor/github.com/danieljoos/wincred/conversion_unsupported.go new file mode 100644 index 0000000..a1ea720 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/conversion_unsupported.go @@ -0,0 +1,11 @@ +// +build !windows + +package wincred + +func utf16ToByte(...interface{}) []byte { + return nil +} + +func utf16FromString(...interface{}) []uint16 { + return nil +} diff --git a/vendor/github.com/danieljoos/wincred/sys.go b/vendor/github.com/danieljoos/wincred/sys.go new file mode 100644 index 0000000..a499970 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/sys.go @@ -0,0 +1,151 @@ +//go:build windows +// +build windows + +package wincred + +import ( + "reflect" + "runtime" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + procCredRead = modadvapi32.NewProc("CredReadW") + procCredWrite proc = modadvapi32.NewProc("CredWriteW") + procCredDelete proc = modadvapi32.NewProc("CredDeleteW") + procCredFree proc = modadvapi32.NewProc("CredFree") + procCredEnumerate = modadvapi32.NewProc("CredEnumerateW") +) + +// Interface for syscall.Proc: helps testing +type proc interface { + Call(a ...uintptr) (r1, r2 uintptr, lastErr error) +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_credentialw +type sysCREDENTIAL struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten windows.Filetime + CredentialBlobSize uint32 + CredentialBlob *byte + Persist uint32 + AttributeCount uint32 + Attributes *sysCREDENTIAL_ATTRIBUTE + TargetAlias *uint16 + UserName *uint16 +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_credential_attributew +type sysCREDENTIAL_ATTRIBUTE struct { + Keyword *uint16 + Flags uint32 + ValueSize uint32 + Value *byte +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_credentialw +type sysCRED_TYPE uint32 + +const ( + sysCRED_TYPE_GENERIC sysCRED_TYPE = 0x1 + sysCRED_TYPE_DOMAIN_PASSWORD sysCRED_TYPE = 0x2 + sysCRED_TYPE_DOMAIN_CERTIFICATE sysCRED_TYPE = 0x3 + sysCRED_TYPE_DOMAIN_VISIBLE_PASSWORD sysCRED_TYPE = 0x4 + sysCRED_TYPE_GENERIC_CERTIFICATE sysCRED_TYPE = 0x5 + sysCRED_TYPE_DOMAIN_EXTENDED sysCRED_TYPE = 0x6 + + // https://docs.microsoft.com/en-us/windows/desktop/Debug/system-error-codes + sysERROR_NOT_FOUND = windows.Errno(1168) + sysERROR_INVALID_PARAMETER = windows.Errno(87) + sysERROR_BAD_USERNAME = windows.Errno(2202) +) + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/nf-wincred-credreadw +func sysCredRead(targetName string, typ sysCRED_TYPE) (*Credential, error) { + var pcred *sysCREDENTIAL + targetNamePtr, _ := windows.UTF16PtrFromString(targetName) + ret, _, err := syscall.SyscallN( + procCredRead.Addr(), + uintptr(unsafe.Pointer(targetNamePtr)), + uintptr(typ), + 0, + uintptr(unsafe.Pointer(&pcred)), + ) + if ret == 0 { + return nil, err + } + defer procCredFree.Call(uintptr(unsafe.Pointer(pcred))) + + return sysToCredential(pcred), nil +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/nf-wincred-credwritew +func sysCredWrite(cred *Credential, typ sysCRED_TYPE) error { + ncred := sysFromCredential(cred) + ncred.Type = uint32(typ) + ret, _, err := procCredWrite.Call( + uintptr(unsafe.Pointer(ncred)), + 0, + ) + // Make sure everything reachable from ncred stays alive through the call. + runtime.KeepAlive(ncred) + if ret == 0 { + return err + } + + return nil +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/nf-wincred-creddeletew +func sysCredDelete(cred *Credential, typ sysCRED_TYPE) error { + targetNamePtr, _ := windows.UTF16PtrFromString(cred.TargetName) + ret, _, err := procCredDelete.Call( + uintptr(unsafe.Pointer(targetNamePtr)), + uintptr(typ), + 0, + ) + if ret == 0 { + return err + } + + return nil +} + +// https://docs.microsoft.com/en-us/windows/desktop/api/wincred/nf-wincred-credenumeratew +func sysCredEnumerate(filter string, all bool) ([]*Credential, error) { + var count int + var pcreds uintptr + var filterPtr *uint16 + if !all { + filterPtr, _ = windows.UTF16PtrFromString(filter) + } + ret, _, err := syscall.SyscallN( + procCredEnumerate.Addr(), + uintptr(unsafe.Pointer(filterPtr)), + 0, + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&pcreds)), + ) + if ret == 0 { + return nil, err + } + defer procCredFree.Call(pcreds) + credsSlice := *(*[]*sysCREDENTIAL)(unsafe.Pointer(&reflect.SliceHeader{ + Data: pcreds, + Len: count, + Cap: count, + })) + creds := make([]*Credential, count, count) + for i, cred := range credsSlice { + creds[i] = sysToCredential(cred) + } + + return creds, nil +} diff --git a/vendor/github.com/danieljoos/wincred/sys_unsupported.go b/vendor/github.com/danieljoos/wincred/sys_unsupported.go new file mode 100644 index 0000000..746639a --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/sys_unsupported.go @@ -0,0 +1,38 @@ +//go:build !windows +// +build !windows + +package wincred + +import ( + "errors" + "syscall" +) + +const ( + sysCRED_TYPE_GENERIC = 0 + sysCRED_TYPE_DOMAIN_PASSWORD = 0 + sysCRED_TYPE_DOMAIN_CERTIFICATE = 0 + sysCRED_TYPE_DOMAIN_VISIBLE_PASSWORD = 0 + sysCRED_TYPE_GENERIC_CERTIFICATE = 0 + sysCRED_TYPE_DOMAIN_EXTENDED = 0 + + sysERROR_NOT_FOUND = syscall.Errno(1) + sysERROR_INVALID_PARAMETER = syscall.Errno(1) + sysERROR_BAD_USERNAME = syscall.Errno(1) +) + +func sysCredRead(...interface{}) (*Credential, error) { + return nil, errors.New("Operation not supported") +} + +func sysCredWrite(...interface{}) error { + return errors.New("Operation not supported") +} + +func sysCredDelete(...interface{}) error { + return errors.New("Operation not supported") +} + +func sysCredEnumerate(...interface{}) ([]*Credential, error) { + return nil, errors.New("Operation not supported") +} diff --git a/vendor/github.com/danieljoos/wincred/types.go b/vendor/github.com/danieljoos/wincred/types.go new file mode 100644 index 0000000..28debc9 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/types.go @@ -0,0 +1,69 @@ +package wincred + +import ( + "time" +) + +// CredentialPersistence describes one of three persistence modes of a credential. +// A detailed description of the available modes can be found on +// Docs: https://docs.microsoft.com/en-us/windows/desktop/api/wincred/ns-wincred-_credentialw +type CredentialPersistence uint32 + +const ( + // PersistSession indicates that the credential only persists for the life + // of the current Windows login session. Such a credential is not visible in + // any other logon session, even from the same user. + PersistSession CredentialPersistence = 0x1 + + // PersistLocalMachine indicates that the credential persists for this and + // all subsequent logon sessions on this local machine/computer. It is + // however not visible for logon sessions of this user on a different + // machine. + PersistLocalMachine CredentialPersistence = 0x2 + + // PersistEnterprise indicates that the credential persists for this and all + // subsequent logon sessions for this user. It is also visible for logon + // sessions on different computers. + PersistEnterprise CredentialPersistence = 0x3 +) + +// CredentialAttribute represents an application-specific attribute of a credential. +type CredentialAttribute struct { + Keyword string + Value []byte +} + +// Credential is the basic credential structure. +// A credential is identified by its target name. +// The actual credential secret is available in the CredentialBlob field. +type Credential struct { + TargetName string + Comment string + LastWritten time.Time + CredentialBlob []byte + Attributes []CredentialAttribute + TargetAlias string + UserName string + Persist CredentialPersistence +} + +// GenericCredential holds a credential for generic usage. +// It is typically defined and used by applications that need to manage user +// secrets. +// +// More information about the available kinds of credentials of the Windows +// Credential Management API can be found on Docs: +// https://docs.microsoft.com/en-us/windows/desktop/SecAuthN/kinds-of-credentials +type GenericCredential struct { + Credential +} + +// DomainPassword holds a domain credential that is typically used by the +// operating system for user logon. +// +// More information about the available kinds of credentials of the Windows +// Credential Management API can be found on Docs: +// https://docs.microsoft.com/en-us/windows/desktop/SecAuthN/kinds-of-credentials +type DomainPassword struct { + Credential +} diff --git a/vendor/github.com/danieljoos/wincred/wincred.go b/vendor/github.com/danieljoos/wincred/wincred.go new file mode 100644 index 0000000..5632ee9 --- /dev/null +++ b/vendor/github.com/danieljoos/wincred/wincred.go @@ -0,0 +1,114 @@ +// Package wincred provides primitives for accessing the Windows Credentials Management API. +// This includes functions for retrieval, listing and storage of credentials as well as Go structures for convenient access to the credential data. +// +// A more detailed description of Windows Credentials Management can be found on +// Docs: https://docs.microsoft.com/en-us/windows/desktop/SecAuthN/credentials-management +package wincred + +import "errors" + +const ( + // ErrElementNotFound is the error that is returned if a requested element cannot be found. + // This error constant can be used to check if a credential could not be found. + ErrElementNotFound = sysERROR_NOT_FOUND + + // ErrInvalidParameter is the error that is returned for invalid parameters. + // This error constant can be used to check if the given function parameters were invalid. + // For example when trying to create a new generic credential with an empty target name. + ErrInvalidParameter = sysERROR_INVALID_PARAMETER + + // ErrBadUsername is returned when the credential's username is invalid. + ErrBadUsername = sysERROR_BAD_USERNAME +) + +// GetGenericCredential fetches the generic credential with the given name from Windows credential manager. +// It returns nil and an error if the credential could not be found or an error occurred. +func GetGenericCredential(targetName string) (*GenericCredential, error) { + cred, err := sysCredRead(targetName, sysCRED_TYPE_GENERIC) + if cred != nil { + return &GenericCredential{Credential: *cred}, err + } + return nil, err +} + +// NewGenericCredential creates a new generic credential object with the given name. +// The persist mode of the newly created object is set to a default value that indicates local-machine-wide storage. +// The credential object is NOT yet persisted to the Windows credential vault. +func NewGenericCredential(targetName string) (result *GenericCredential) { + result = new(GenericCredential) + result.TargetName = targetName + result.Persist = PersistLocalMachine + return +} + +// Write persists the generic credential object to Windows credential manager. +func (t *GenericCredential) Write() (err error) { + err = sysCredWrite(&t.Credential, sysCRED_TYPE_GENERIC) + return +} + +// Delete removes the credential object from Windows credential manager. +func (t *GenericCredential) Delete() (err error) { + err = sysCredDelete(&t.Credential, sysCRED_TYPE_GENERIC) + return +} + +// GetDomainPassword fetches the domain-password credential with the given target host name from Windows credential manager. +// It returns nil and an error if the credential could not be found or an error occurred. +func GetDomainPassword(targetName string) (*DomainPassword, error) { + cred, err := sysCredRead(targetName, sysCRED_TYPE_DOMAIN_PASSWORD) + if cred != nil { + return &DomainPassword{Credential: *cred}, err + } + return nil, err +} + +// NewDomainPassword creates a new domain-password credential used for login to the given target host name. +// The persist mode of the newly created object is set to a default value that indicates local-machine-wide storage. +// The credential object is NOT yet persisted to the Windows credential vault. +func NewDomainPassword(targetName string) (result *DomainPassword) { + result = new(DomainPassword) + result.TargetName = targetName + result.Persist = PersistLocalMachine + return +} + +// Write persists the domain-password credential to Windows credential manager. +func (t *DomainPassword) Write() (err error) { + err = sysCredWrite(&t.Credential, sysCRED_TYPE_DOMAIN_PASSWORD) + return +} + +// Delete removes the domain-password credential from Windows credential manager. +func (t *DomainPassword) Delete() (err error) { + err = sysCredDelete(&t.Credential, sysCRED_TYPE_DOMAIN_PASSWORD) + return +} + +// SetPassword sets the CredentialBlob field of a domain password credential to the given string. +func (t *DomainPassword) SetPassword(pw string) { + t.CredentialBlob = utf16ToByte(utf16FromString(pw)) +} + +// List retrieves all credentials of the Credentials store. +func List() ([]*Credential, error) { + creds, err := sysCredEnumerate("", true) + if err != nil && errors.Is(err, ErrElementNotFound) { + // Ignore ERROR_NOT_FOUND and return an empty list instead + creds = []*Credential{} + err = nil + } + return creds, err +} + +// FilteredList retrieves the list of credentials from the Credentials store that match the given filter. +// The filter string defines the prefix followed by an asterisk for the `TargetName` attribute of the credentials. +func FilteredList(filter string) ([]*Credential, error) { + creds, err := sysCredEnumerate(filter, false) + if err != nil && errors.Is(err, ErrElementNotFound) { + // Ignore ERROR_NOT_FOUND and return an empty list instead + creds = []*Credential{} + err = nil + } + return creds, err +} diff --git a/vendor/github.com/godbus/dbus/v5/.cirrus.yml b/vendor/github.com/godbus/dbus/v5/.cirrus.yml new file mode 100644 index 0000000..6e20902 --- /dev/null +++ b/vendor/github.com/godbus/dbus/v5/.cirrus.yml @@ -0,0 +1,11 @@ +# See https://cirrus-ci.org/guide/FreeBSD/ +freebsd_instance: + image_family: freebsd-14-3 + +task: + name: Test on FreeBSD + install_script: pkg install -y go125 dbus + test_script: | + /usr/local/etc/rc.d/dbus onestart && \ + eval `dbus-launch --sh-syntax` && \ + go125 test -v ./... diff --git a/vendor/github.com/godbus/dbus/v5/.golangci.yml b/vendor/github.com/godbus/dbus/v5/.golangci.yml new file mode 100644 index 0000000..5bbdd93 --- /dev/null +++ b/vendor/github.com/godbus/dbus/v5/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" + +linters: + enable: + - unconvert + - unparam + exclusions: + presets: + - std-error-handling + +formatters: + enable: + - gofumpt diff --git a/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md b/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md new file mode 100644 index 0000000..c88f9b2 --- /dev/null +++ b/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# How to Contribute + +## Getting Started + +- Fork the repository on GitHub +- Read the [README](README.markdown) for build and test instructions +- Play with the project, submit bugs, submit patches! + +## Contribution Flow + +This is a rough outline of what a contributor's workflow looks like: + +- Create a topic branch from where you want to base your work (usually master). +- Make commits of logical units. +- Make sure your commit messages are in the proper format (see below). +- Push your changes to a topic branch in your fork of the repository. +- Make sure the tests pass, and add any new tests as appropriate. +- Submit a pull request to the original repository. + +Thanks for your contributions! + +### Format of the Commit Message + +We follow a rough convention for commit messages that is designed to answer two +questions: what changed and why. The subject line should feature the what and +the body of the commit should describe the why. + +``` +scripts: add the test-cluster command + +this uses tmux to setup a test cluster that you can easily kill and +start for debugging. + +Fixes #38 +``` + +The format can be described more formally as follows: + +``` +: + + + +