Skip to content

Commit 8c2dfc2

Browse files
JAORMXclaude
andauthored
Package Gemini CLI as a built-in agent (#140)
Adds Google's @google/gemini-cli as the fifth built-in agent, using the same registry + image + MCP injection pattern as the existing four. MCP config is written to ~/.gemini/settings.json via the new MCPConfigFormatGemini format, using `mcpServers.sandbox-tools.httpUrl` (HTTP streaming, matching what vmcp serves). The settings injector filters host-side settings.json to host-portable categories only — mcpServers, tools, hooks, security, advanced, and telemetry are intentionally excluded so host-only servers and host-coupled toggles never leak into the guest. Auth follows the codex/opencode pattern: first-run OAuth in the VM, persisted between sessions via CredentialPaths. The locked egress profile covers the Gemini Developer API, Vertex AI, Code Assist, and Google OAuth endpoints. Also restores hermes to image-push, which was missing from the prior Hermes change. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 54e8d73 commit 8c2dfc2

13 files changed

Lines changed: 169 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ The workflow:
116116
bbox claude-code
117117
bbox codex
118118
bbox opencode
119+
bbox gemini
119120

120121
# Override resources
121122
bbox claude-code --cpus 4 --memory 4096
@@ -281,6 +282,8 @@ bbox claude-code --allow-host "my-registry.example.com:443"
281282
| Claude Code | `bbox claude-code` | `ghcr.io/stacklok/brood-box/claude-code` | 2 vCPUs, 4 GiB RAM |
282283
| Codex | `bbox codex` | `ghcr.io/stacklok/brood-box/codex` | 2 vCPUs, 4 GiB RAM |
283284
| OpenCode | `bbox opencode` | `ghcr.io/stacklok/brood-box/opencode` | 2 vCPUs, 4 GiB RAM |
285+
| Hermes | `bbox hermes` | `ghcr.io/stacklok/brood-box/hermes` | 2 vCPUs, 4 GiB RAM |
286+
| Gemini CLI | `bbox gemini` | `ghcr.io/stacklok/brood-box/gemini` | 2 vCPUs, 4 GiB RAM |
284287

285288
You can also define custom agents in your config:
286289

Taskfile.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,15 @@ tasks:
268268
cmds:
269269
- "{{.CONTAINER_ENGINE}} build -t {{.IMAGE_REGISTRY}}/hermes:latest images/hermes/"
270270

271+
image-gemini:
272+
desc: Build gemini guest image
273+
deps: [image-base]
274+
cmds:
275+
- "{{.CONTAINER_ENGINE}} build -t {{.IMAGE_REGISTRY}}/gemini:latest images/gemini/"
276+
271277
image-all:
272278
desc: Build all guest images
273-
deps: [image-claude-code, image-codex, image-opencode, image-hermes]
279+
deps: [image-claude-code, image-codex, image-opencode, image-hermes, image-gemini]
274280

275281
image-push:
276282
desc: Push all images to GHCR
@@ -281,3 +287,4 @@ tasks:
281287
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/codex:latest"
282288
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/opencode:latest"
283289
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/hermes:latest"
290+
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/gemini:latest"

docker-bake.hcl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ variable "REGISTRY" {
66
}
77

88
group "default" {
9-
targets = ["base", "claude-code", "codex", "opencode", "hermes"]
9+
targets = ["base", "claude-code", "codex", "opencode", "hermes", "gemini"]
1010
}
1111

1212
target "base" {
@@ -72,3 +72,17 @@ target "hermes" {
7272
"brood-box-base" = "target:base"
7373
}
7474
}
75+
76+
target "gemini" {
77+
context = "images/gemini/"
78+
platforms = ["linux/amd64", "linux/arm64"]
79+
tags = ["${REGISTRY}/gemini:latest"]
80+
cache-from = ["type=gha,scope=gemini"]
81+
cache-to = ["type=gha,mode=max,scope=gemini"]
82+
args = {
83+
BASE_IMAGE = "brood-box-base"
84+
}
85+
contexts = {
86+
"brood-box-base" = "target:base"
87+
}
88+
}

docs/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ Concrete implementations of domain interfaces and system integration.
123123
`$XDG_CONFIG_HOME/broodbox/config.yaml` with graceful fallback
124124
when the file doesn't exist.
125125
- **`agent/registry.go`** -- In-memory `Registry` pre-loaded with
126-
built-in agents (claude-code, codex, opencode). Supports adding
127-
custom agents from config.
126+
built-in agents (claude-code, codex, opencode, hermes, gemini).
127+
Supports adding custom agents from config.
128128
- **`exclude/`** -- Two-tier gitignore-compatible pattern matching.
129129
Security patterns are non-overridable; performance patterns can be
130130
negated in `.broodboxignore`.
@@ -270,7 +270,7 @@ bbox claude-code
270270
SSH session:
271271
source /etc/sandbox-env
272272
cd /workspace
273-
exec claude (or codex, opencode, etc.)
273+
exec claude (or codex, opencode, hermes, gemini, etc.)
274274
275275
276276
Agent exits → SSH session ends → VM stopped

docs/DEVELOPMENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ task verify
5252
| `task image-claude-code` | Build claude-code guest image |
5353
| `task image-codex` | Build codex guest image |
5454
| `task image-opencode` | Build opencode guest image |
55+
| `task image-gemini` | Build gemini guest image |
5556
| `task image-all` | Build all guest images |
5657
| `task image-push` | Push all images to GHCR |
5758

docs/USER_GUIDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ ls -la /dev/kvm
3939
| `claude-code` | `ghcr.io/stacklok/brood-box/claude-code:latest` | `claude` | `ANTHROPIC_API_KEY`, `CLAUDE_*` |
4040
| `codex` | `ghcr.io/stacklok/brood-box/codex:latest` | `codex` | `OPENAI_API_KEY`, `CODEX_*` |
4141
| `opencode` | `ghcr.io/stacklok/brood-box/opencode:latest` | `opencode` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `OPENCODE_*` |
42+
| `gemini` | `ghcr.io/stacklok/brood-box/gemini:latest` | `gemini` | `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`, `GOOGLE_GENAI_USE_VERTEXAI`, `GOOGLE_APPLICATION_CREDENTIALS`, `GEMINI_*` |
4243

4344
All agents default to the `permissive` egress profile.
4445

@@ -553,6 +554,13 @@ of what gets injected.
553554
- `AGENTS.md` — Instructions (+ Claude Code `CLAUDE.md` as fallback)
554555
- `agents/`, `skills/`, `commands/`, `tools/`, `plugins/`, `themes/` — Directories
555556

557+
**Gemini CLI** (`~/.gemini/`):
558+
- `settings.json` — Settings (JSON; filtered to host-portable categories
559+
only — `mcpServers`, `tools`, `hooks`, `security`, `advanced`, `telemetry`,
560+
and `policyPaths` are intentionally **not** copied)
561+
- `GEMINI.md` — Instructions (+ Claude Code `CLAUDE.md` as `~/.gemini/CLAUDE.md` fallback)
562+
- `agents/`, `skills/`, `commands/` — Directories (+ `~/.agents/skills/` fallback)
563+
556564
### Security
557565

558566
- **Allowlist filtering**: Only explicitly listed config keys are copied.
@@ -622,6 +630,7 @@ This builds the base image first, then all three agent images in parallel:
622630
| `ghcr.io/stacklok/brood-box/claude-code:latest` | Base + Claude Code binary |
623631
| `ghcr.io/stacklok/brood-box/codex:latest` | Base + Codex binary |
624632
| `ghcr.io/stacklok/brood-box/opencode:latest` | Base + OpenCode binary |
633+
| `ghcr.io/stacklok/brood-box/gemini:latest` | Base + Gemini CLI (`@google/gemini-cli`) |
625634

626635
### Build Individual Images
627636

@@ -630,6 +639,7 @@ task image-base # Base image only
630639
task image-claude-code # Claude Code (builds base if needed)
631640
task image-codex # Codex (builds base if needed)
632641
task image-opencode # OpenCode (builds base if needed)
642+
task image-gemini # Gemini CLI (builds base if needed)
633643
```
634644

635645
### Push to GHCR

images/gemini/Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Gemini CLI guest image for Brood Box.
5+
# Installs Google's @google/gemini-cli (Node/Ink-based) on top of the base
6+
# image, which already provides nodejs and npm from the Wolfi repo.
7+
ARG BASE_IMAGE=ghcr.io/stacklok/brood-box/base:latest
8+
FROM ${BASE_IMAGE}
9+
10+
# Install Gemini CLI globally. Requires Node.js 20+; the Wolfi base
11+
# `nodejs` apk satisfies this as of 2026. If a future Wolfi default
12+
# regresses below 20, pin a versioned package (e.g. nodejs-22) here.
13+
RUN npm install -g --omit=dev @google/gemini-cli && \
14+
npm cache clean --force

internal/infra/agent/registry.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ func builtinAgents() map[string]domainagent.Agent {
7878
{Name: "*.nousresearch.com", Ports: []uint16{443}},
7979
}
8080

81+
// Gemini CLI defaults to OAuth (Code Assist endpoint) but also supports
82+
// direct Gemini Developer API and Vertex AI. Locked profile covers all
83+
// three plus the Google OAuth endpoints needed for first-run sign-in.
84+
geminiLockedHosts := []egress.Host{
85+
{Name: "generativelanguage.googleapis.com", Ports: []uint16{443}},
86+
{Name: "aiplatform.googleapis.com", Ports: []uint16{443}},
87+
{Name: "cloudaicompanion.googleapis.com", Ports: []uint16{443}},
88+
{Name: "oauth2.googleapis.com", Ports: []uint16{443}},
89+
{Name: "accounts.google.com", Ports: []uint16{443}},
90+
}
91+
8192
return map[string]domainagent.Agent{
8293
"claude-code": {
8394
Name: "claude-code",
@@ -225,6 +236,69 @@ func builtinAgents() map[string]domainagent.Agent {
225236
{Category: "skills", HostPath: ".agents/skills", GuestPath: ".agents/skills", Kind: settings.KindDirectory, Optional: true},
226237
}},
227238
},
239+
"gemini": {
240+
Name: "gemini",
241+
Image: "ghcr.io/stacklok/brood-box/gemini:latest",
242+
Command: []string{"gemini"},
243+
// GEMINI_API_KEY: Gemini Developer API key.
244+
// GOOGLE_API_KEY: Vertex AI express-mode key.
245+
// GOOGLE_CLOUD_PROJECT/LOCATION: Vertex AI / Code Assist.
246+
// GOOGLE_GENAI_USE_VERTEXAI: switch to Vertex.
247+
// GOOGLE_APPLICATION_CREDENTIALS is forwarded by name only —
248+
// the pointed-to JSON file is NOT auto-injected. Users who
249+
// need ADC inside the VM must set GEMINI_API_KEY instead or
250+
// inject the credential file themselves.
251+
// GEMINI_*: catch-all for documented Gemini knobs (e.g.
252+
// GEMINI_TELEMETRY_ENABLED, GEMINI_SYSTEM_MD).
253+
EnvForward: []string{
254+
"GEMINI_API_KEY",
255+
"GOOGLE_API_KEY",
256+
"GOOGLE_CLOUD_PROJECT",
257+
"GOOGLE_CLOUD_LOCATION",
258+
"GOOGLE_GENAI_USE_VERTEXAI",
259+
"GOOGLE_APPLICATION_CREDENTIALS",
260+
"GEMINI_*",
261+
},
262+
NodeHeapPercent: 75,
263+
GoMemLimitPercent: 70,
264+
DefaultCPUs: 2,
265+
DefaultMemory: bytesize.ByteSize(4096),
266+
DefaultTmpSize: bytesize.ByteSize(2048),
267+
DefaultEgressProfile: egress.ProfilePermissive,
268+
MCPConfigFormat: domainagent.MCPConfigFormatGemini,
269+
CredentialPaths: []string{".gemini/"},
270+
EgressHosts: map[egress.ProfileName][]egress.Host{
271+
egress.ProfileLocked: geminiLockedHosts,
272+
egress.ProfileStandard: append(geminiLockedHosts, devInfraHosts...),
273+
},
274+
// NOTE: "mcpServers", "mcp", "tools", "hooks", "hooksConfig",
275+
// "security", "advanced", "telemetry", "policyPaths",
276+
// "adminPolicyPaths", "admin", and "ide" are intentionally
277+
// excluded from AllowKeys. mcpServers would point the guest at
278+
// host-only servers; tools.discoveryCommand/callCommand and
279+
// hooks reference host-side executables; security/advanced
280+
// control env-var redaction and other host-coupled toggles.
281+
// Only host-portable categories are allowed through.
282+
SettingsManifest: &settings.Manifest{Entries: []settings.Entry{
283+
{Category: "settings", HostPath: ".gemini/settings.json", GuestPath: ".gemini/settings.json", Kind: settings.KindMergeFile, Optional: true,
284+
Format: "json", Filter: &settings.FieldFilter{AllowKeys: []string{
285+
"general", "ui", "model", "modelConfigs", "context",
286+
"agents", "skills", "useWriteTodos", "experimental",
287+
"output", "privacy",
288+
}}},
289+
// GEMINI.md is the user-level memory/context file —
290+
// directly analogous to ~/.claude/CLAUDE.md.
291+
{Category: "instructions", HostPath: ".gemini/GEMINI.md", GuestPath: ".gemini/GEMINI.md", Kind: settings.KindFile, Optional: true},
292+
// Cross-tool fallback: users with a single CLAUDE.md on
293+
// the host get context in Gemini too. Gemini's
294+
// `context.fileName` accepts multiple filenames.
295+
{Category: "instructions", HostPath: ".claude/CLAUDE.md", GuestPath: ".gemini/CLAUDE.md", Kind: settings.KindFile, Optional: true},
296+
{Category: "agents", HostPath: ".gemini/agents", GuestPath: ".gemini/agents", Kind: settings.KindDirectory, Optional: true},
297+
{Category: "skills", HostPath: ".gemini/skills", GuestPath: ".gemini/skills", Kind: settings.KindDirectory, Optional: true},
298+
{Category: "skills", HostPath: ".agents/skills", GuestPath: ".agents/skills", Kind: settings.KindDirectory, Optional: true},
299+
{Category: "commands", HostPath: ".gemini/commands", GuestPath: ".gemini/commands", Kind: settings.KindDirectory, Optional: true},
300+
}},
301+
},
228302
}
229303
}
230304

internal/infra/agent/registry_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestNewRegistry_ContainsBuiltInAgents(t *testing.T) {
2020
reg := NewRegistry()
2121
agents := reg.List()
2222

23-
require.Len(t, agents, 4, "registry should contain exactly 4 built-in agents")
23+
require.Len(t, agents, 5, "registry should contain exactly 5 built-in agents")
2424

2525
names := make(map[string]bool, len(agents))
2626
for _, a := range agents {
@@ -34,6 +34,7 @@ func TestNewRegistry_ContainsBuiltInAgents(t *testing.T) {
3434
assert.True(t, names["codex"], "registry should contain codex")
3535
assert.True(t, names["opencode"], "registry should contain opencode")
3636
assert.True(t, names["hermes"], "registry should contain hermes")
37+
assert.True(t, names["gemini"], "registry should contain gemini")
3738
}
3839

3940
func TestRegistry_Get_BuiltInAgent(t *testing.T) {
@@ -48,6 +49,7 @@ func TestRegistry_Get_BuiltInAgent(t *testing.T) {
4849
{name: "codex"},
4950
{name: "opencode"},
5051
{name: "hermes"},
52+
{name: "gemini"},
5153
}
5254

5355
for _, tt := range tests {
@@ -163,7 +165,7 @@ func TestRegistry_List_SortedByName(t *testing.T) {
163165
require.NoError(t, err)
164166

165167
agents := reg.List()
166-
require.Len(t, agents, 6)
168+
require.Len(t, agents, 7)
167169

168170
for i := 1; i < len(agents); i++ {
169171
assert.True(t, agents[i-1].Name < agents[i].Name,

internal/infra/vm/hooks.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ func InjectMCPConfig(format domainagent.MCPConfigFormat, gatewayIP string, port
4040
return injectOpenCodeMCP(rootfsPath, gatewayIP, port, chown)
4141
case domainagent.MCPConfigFormatHermes:
4242
return injectHermesMCP(rootfsPath, gatewayIP, port, chown)
43+
case domainagent.MCPConfigFormatGemini:
44+
return injectGeminiMCP(rootfsPath, gatewayIP, port, chown)
4345
default:
4446
return nil
4547
}

0 commit comments

Comments
 (0)