Skip to content

Commit 5735dad

Browse files
committed
Treat a Vertex service-account JSON as ADC
AIXGO_VERTEX_API_KEY may be a plain API key or the contents of a service-account JSON. JSON is written to a temp file and exported as GOOGLE_APPLICATION_CREDENTIALS so Gemini CLI authenticates with IAM instead of an API token.
1 parent 29737b1 commit 5735dad

4 files changed

Lines changed: 96 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ GitHub release notes for each tag.
88
- **Gemini on Vertex AI** (`engine: gemini`). The loop, roles, and gate are
99
unchanged. The adapter shells out to the Gemini CLI (`gemini -p`) and reads
1010
`AIXGO_VERTEX_PROJECT`, `AIXGO_VERTEX_LOCATION` (default `us-central1`), and
11-
`AIXGO_VERTEX_API_KEY`. Azure credentials are not required on this path.
11+
`AIXGO_VERTEX_API_KEY`. If that last value is a service-account JSON object
12+
(`"type": "service_account"`), it is written to a temp file and exported as
13+
`GOOGLE_APPLICATION_CREDENTIALS`; a plain API key is still passed as
14+
`GOOGLE_API_KEY`. Azure credentials are not required on this path.
1215
- The reusable workflow accepts optional `vertex-project`, `vertex-location`,
1316
and `vertex-api-key`, and the Azure endpoint/key are no longer required
1417
inputs. A Codex adopter who already passes them is unchanged. A Gemini

‎docs/setup.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,9 @@ engine: gemini
289289
```sh
290290
export AIXGO_VERTEX_PROJECT="<project>"
291291
export AIXGO_VERTEX_LOCATION="us-central1" # optional, this is the default
292-
export AIXGO_VERTEX_API_KEY="<key>"
292+
# Either a Vertex API key, or the contents of a service-account JSON key
293+
# (a value starting with {"type":"service_account"...} is used as ADC).
294+
export AIXGO_VERTEX_API_KEY="<key-or-sa.json-contents>"
293295
aixgo-code run owner/repo#123 --repo-dir .
294296
```
295297

‎internal/engine/gemini/gemini.go‎

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package gemini
1212

1313
import (
1414
"context"
15+
"encoding/json"
1516
"errors"
1617
"fmt"
1718
"os"
@@ -53,6 +54,12 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu
5354
return domain.RunResult{Role: req.Role, Err: err}, err
5455
}
5556

57+
credsPath, cleanup, err := materializeVertexCreds()
58+
if err != nil {
59+
return domain.RunResult{Role: req.Role, Err: err}, err
60+
}
61+
defer cleanup()
62+
5663
args := []string{"-p", req.Prompt, "--output-format", "text", "-y"}
5764
model := req.Model
5865
if model == "" {
@@ -64,7 +71,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu
6471

6572
cmd := exec.CommandContext(ctx, r.bin(), args...)
6673
cmd.Dir = req.WorkDir
67-
cmd.Env = r.childEnv(req.WorkDir)
74+
cmd.Env = r.childEnv(req.WorkDir, credsPath)
6875
out, runErr := cmd.CombinedOutput()
6976

7077
res := domain.RunResult{Role: req.Role, Summary: tail(strings.TrimSpace(string(out)), 40)}
@@ -78,10 +85,11 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (domain.RunResu
7885
// childEnv builds the child environment. It mirrors the other adapters for the
7986
// Go caches, and translates the AIXGO_VERTEX_* names into the names the Gemini
8087
// CLI reads for Vertex.
81-
func (r *Runner) childEnv(workDir string) []string {
88+
func (r *Runner) childEnv(workDir, credsPath string) []string {
8289
env := filterEnvKeys(os.Environ(),
8390
"GOCACHE", "GOPATH", "GH_TOKEN", "GITHUB_TOKEN",
84-
"GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_API_KEY",
91+
"GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION",
92+
"GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS",
8593
)
8694
env = append(env, r.ExtraEnv...)
8795
if !hasEnvKey(r.ExtraEnv, "GOCACHE") {
@@ -103,12 +111,51 @@ func (r *Runner) childEnv(workDir string) []string {
103111
loc = defaultLocation
104112
}
105113
env = append(env, "GOOGLE_CLOUD_LOCATION="+loc)
106-
if k := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")); k != "" {
114+
if credsPath != "" {
115+
env = append(env, "GOOGLE_APPLICATION_CREDENTIALS="+credsPath)
116+
} else if k := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY")); k != "" {
107117
env = append(env, "GOOGLE_API_KEY="+k)
108118
}
109119
return env
110120
}
111121

122+
// materializeVertexCreds writes a service-account JSON from AIXGO_VERTEX_API_KEY
123+
// to a temp file outside the worktree. A plain API key is left in the env and
124+
// handled by childEnv. The file is 0600 and must be removed by the caller.
125+
func materializeVertexCreds() (string, func(), error) {
126+
noop := func() {}
127+
raw := strings.TrimSpace(os.Getenv("AIXGO_VERTEX_API_KEY"))
128+
if raw == "" || !isServiceAccountJSON(raw) {
129+
return "", noop, nil
130+
}
131+
f, err := os.CreateTemp("", "aixgo-vertex-sa-*.json")
132+
if err != nil {
133+
return "", noop, fmt.Errorf("gemini: write service account key: %w", err)
134+
}
135+
path := f.Name()
136+
cleanup := func() { _ = os.Remove(path) }
137+
if _, err := f.WriteString(raw); err != nil {
138+
_ = f.Close()
139+
cleanup()
140+
return "", noop, fmt.Errorf("gemini: write service account key: %w", err)
141+
}
142+
if err := f.Close(); err != nil {
143+
cleanup()
144+
return "", noop, fmt.Errorf("gemini: write service account key: %w", err)
145+
}
146+
return path, cleanup, nil
147+
}
148+
149+
func isServiceAccountJSON(s string) bool {
150+
var doc struct {
151+
Type string `json:"type"`
152+
}
153+
if err := json.Unmarshal([]byte(s), &doc); err != nil {
154+
return false
155+
}
156+
return doc.Type == "service_account"
157+
}
158+
112159
func filterEnvKeys(env []string, keys ...string) []string {
113160
out := make([]string, 0, len(env))
114161
for _, e := range env {

‎internal/engine/gemini/gemini_test.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,44 @@ func TestRunExportsVertexSettingsForTheGeminiCLI(t *testing.T) {
116116
}
117117
}
118118

119+
const echoADC = `#!/bin/sh
120+
printf '%s\n%s\n' "$GOOGLE_API_KEY" "$GOOGLE_APPLICATION_CREDENTIALS"
121+
if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ]; then
122+
cat "$GOOGLE_APPLICATION_CREDENTIALS"
123+
fi
124+
exit 0
125+
`
126+
127+
const saJSON = `{"type":"service_account","project_id":"simplycubed-agents","client_email":"vertex@simplycubed-agents.iam.gserviceaccount.com"}`
128+
129+
func TestRunTreatsServiceAccountJSONAsADC(t *testing.T) {
130+
work := t.TempDir()
131+
t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents")
132+
t.Setenv("AIXGO_VERTEX_API_KEY", saJSON)
133+
r := New()
134+
r.Bin = writeFakeGemini(t, echoADC)
135+
res, err := r.Run(context.Background(), domain.RunRequest{
136+
Role: domain.RoleImplementer, WorkDir: work, Prompt: "x",
137+
})
138+
if err != nil {
139+
t.Fatalf("unexpected error: %v", err)
140+
}
141+
lines := strings.SplitN(res.Summary, "\n", 2)
142+
if len(lines) < 2 {
143+
t.Fatalf("summary = %q", res.Summary)
144+
}
145+
creds := lines[0]
146+
if creds == "" || strings.HasPrefix(creds, "{") {
147+
t.Fatalf("GOOGLE_APPLICATION_CREDENTIALS must be a file path, not the JSON, got %q", creds)
148+
}
149+
if strings.HasPrefix(creds, work) {
150+
t.Fatalf("the key file must not live in the worktree, got %q", creds)
151+
}
152+
if !strings.Contains(lines[1], `"type":"service_account"`) {
153+
t.Fatalf("the key file should contain the service account JSON, got %q", res.Summary)
154+
}
155+
}
156+
119157
func TestRunDefaultsLocationWhenUnset(t *testing.T) {
120158
t.Setenv("AIXGO_VERTEX_PROJECT", "p")
121159
t.Setenv("AIXGO_VERTEX_LOCATION", "")

0 commit comments

Comments
 (0)