Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ The workflow:
bbox claude-code
bbox codex
bbox opencode
bbox gemini

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

You can also define custom agents in your config:

Expand Down
9 changes: 8 additions & 1 deletion Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,15 @@ tasks:
cmds:
- "{{.CONTAINER_ENGINE}} build -t {{.IMAGE_REGISTRY}}/hermes:latest images/hermes/"

image-gemini:
desc: Build gemini guest image
deps: [image-base]
cmds:
- "{{.CONTAINER_ENGINE}} build -t {{.IMAGE_REGISTRY}}/gemini:latest images/gemini/"

image-all:
desc: Build all guest images
deps: [image-claude-code, image-codex, image-opencode, image-hermes]
deps: [image-claude-code, image-codex, image-opencode, image-hermes, image-gemini]

image-push:
desc: Push all images to GHCR
Expand All @@ -281,3 +287,4 @@ tasks:
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/codex:latest"
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/opencode:latest"
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/hermes:latest"
- "{{.CONTAINER_ENGINE}} push {{.IMAGE_REGISTRY}}/gemini:latest"
16 changes: 15 additions & 1 deletion docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ variable "REGISTRY" {
}

group "default" {
targets = ["base", "claude-code", "codex", "opencode", "hermes"]
targets = ["base", "claude-code", "codex", "opencode", "hermes", "gemini"]
}

target "base" {
Expand Down Expand Up @@ -72,3 +72,17 @@ target "hermes" {
"brood-box-base" = "target:base"
}
}

target "gemini" {
context = "images/gemini/"
platforms = ["linux/amd64", "linux/arm64"]
tags = ["${REGISTRY}/gemini:latest"]
cache-from = ["type=gha,scope=gemini"]
cache-to = ["type=gha,mode=max,scope=gemini"]
args = {
BASE_IMAGE = "brood-box-base"
}
contexts = {
"brood-box-base" = "target:base"
}
}
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ Concrete implementations of domain interfaces and system integration.
`$XDG_CONFIG_HOME/broodbox/config.yaml` with graceful fallback
when the file doesn't exist.
- **`agent/registry.go`** -- In-memory `Registry` pre-loaded with
built-in agents (claude-code, codex, opencode). Supports adding
custom agents from config.
built-in agents (claude-code, codex, opencode, hermes, gemini).
Supports adding custom agents from config.
- **`exclude/`** -- Two-tier gitignore-compatible pattern matching.
Security patterns are non-overridable; performance patterns can be
negated in `.broodboxignore`.
Expand Down Expand Up @@ -270,7 +270,7 @@ bbox claude-code
SSH session:
source /etc/sandbox-env
cd /workspace
exec claude (or codex, opencode, etc.)
exec claude (or codex, opencode, hermes, gemini, etc.)
Agent exits → SSH session ends → VM stopped
Expand Down
1 change: 1 addition & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ task verify
| `task image-claude-code` | Build claude-code guest image |
| `task image-codex` | Build codex guest image |
| `task image-opencode` | Build opencode guest image |
| `task image-gemini` | Build gemini guest image |
| `task image-all` | Build all guest images |
| `task image-push` | Push all images to GHCR |

Expand Down
10 changes: 10 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ ls -la /dev/kvm
| `claude-code` | `ghcr.io/stacklok/brood-box/claude-code:latest` | `claude` | `ANTHROPIC_API_KEY`, `CLAUDE_*` |
| `codex` | `ghcr.io/stacklok/brood-box/codex:latest` | `codex` | `OPENAI_API_KEY`, `CODEX_*` |
| `opencode` | `ghcr.io/stacklok/brood-box/opencode:latest` | `opencode` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `OPENCODE_*` |
| `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_*` |

All agents default to the `permissive` egress profile.

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

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

### Security

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

### Build Individual Images

Expand All @@ -630,6 +639,7 @@ task image-base # Base image only
task image-claude-code # Claude Code (builds base if needed)
task image-codex # Codex (builds base if needed)
task image-opencode # OpenCode (builds base if needed)
task image-gemini # Gemini CLI (builds base if needed)
```

### Push to GHCR
Expand Down
14 changes: 14 additions & 0 deletions images/gemini/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
# SPDX-License-Identifier: Apache-2.0

# Gemini CLI guest image for Brood Box.
# Installs Google's @google/gemini-cli (Node/Ink-based) on top of the base
# image, which already provides nodejs and npm from the Wolfi repo.
ARG BASE_IMAGE=ghcr.io/stacklok/brood-box/base:latest
FROM ${BASE_IMAGE}

# Install Gemini CLI globally. Requires Node.js 20+; the Wolfi base
# `nodejs` apk satisfies this as of 2026. If a future Wolfi default
# regresses below 20, pin a versioned package (e.g. nodejs-22) here.
RUN npm install -g --omit=dev @google/gemini-cli && \
npm cache clean --force
74 changes: 74 additions & 0 deletions internal/infra/agent/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ func builtinAgents() map[string]domainagent.Agent {
{Name: "*.nousresearch.com", Ports: []uint16{443}},
}

// Gemini CLI defaults to OAuth (Code Assist endpoint) but also supports
// direct Gemini Developer API and Vertex AI. Locked profile covers all
// three plus the Google OAuth endpoints needed for first-run sign-in.
geminiLockedHosts := []egress.Host{
{Name: "generativelanguage.googleapis.com", Ports: []uint16{443}},
{Name: "aiplatform.googleapis.com", Ports: []uint16{443}},
{Name: "cloudaicompanion.googleapis.com", Ports: []uint16{443}},
{Name: "oauth2.googleapis.com", Ports: []uint16{443}},
{Name: "accounts.google.com", Ports: []uint16{443}},
}

return map[string]domainagent.Agent{
"claude-code": {
Name: "claude-code",
Expand Down Expand Up @@ -225,6 +236,69 @@ func builtinAgents() map[string]domainagent.Agent {
{Category: "skills", HostPath: ".agents/skills", GuestPath: ".agents/skills", Kind: settings.KindDirectory, Optional: true},
}},
},
"gemini": {
Name: "gemini",
Image: "ghcr.io/stacklok/brood-box/gemini:latest",
Command: []string{"gemini"},
// GEMINI_API_KEY: Gemini Developer API key.
// GOOGLE_API_KEY: Vertex AI express-mode key.
// GOOGLE_CLOUD_PROJECT/LOCATION: Vertex AI / Code Assist.
// GOOGLE_GENAI_USE_VERTEXAI: switch to Vertex.
// GOOGLE_APPLICATION_CREDENTIALS is forwarded by name only —
// the pointed-to JSON file is NOT auto-injected. Users who
// need ADC inside the VM must set GEMINI_API_KEY instead or
// inject the credential file themselves.
// GEMINI_*: catch-all for documented Gemini knobs (e.g.
// GEMINI_TELEMETRY_ENABLED, GEMINI_SYSTEM_MD).
EnvForward: []string{
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_GENAI_USE_VERTEXAI",
"GOOGLE_APPLICATION_CREDENTIALS",
"GEMINI_*",
},
NodeHeapPercent: 75,
GoMemLimitPercent: 70,
DefaultCPUs: 2,
DefaultMemory: bytesize.ByteSize(4096),
DefaultTmpSize: bytesize.ByteSize(2048),
DefaultEgressProfile: egress.ProfilePermissive,
MCPConfigFormat: domainagent.MCPConfigFormatGemini,
CredentialPaths: []string{".gemini/"},
EgressHosts: map[egress.ProfileName][]egress.Host{
egress.ProfileLocked: geminiLockedHosts,
egress.ProfileStandard: append(geminiLockedHosts, devInfraHosts...),
},
// NOTE: "mcpServers", "mcp", "tools", "hooks", "hooksConfig",
// "security", "advanced", "telemetry", "policyPaths",
// "adminPolicyPaths", "admin", and "ide" are intentionally
// excluded from AllowKeys. mcpServers would point the guest at
// host-only servers; tools.discoveryCommand/callCommand and
// hooks reference host-side executables; security/advanced
// control env-var redaction and other host-coupled toggles.
// Only host-portable categories are allowed through.
SettingsManifest: &settings.Manifest{Entries: []settings.Entry{
{Category: "settings", HostPath: ".gemini/settings.json", GuestPath: ".gemini/settings.json", Kind: settings.KindMergeFile, Optional: true,
Format: "json", Filter: &settings.FieldFilter{AllowKeys: []string{
"general", "ui", "model", "modelConfigs", "context",
"agents", "skills", "useWriteTodos", "experimental",
"output", "privacy",
}}},
// GEMINI.md is the user-level memory/context file —
// directly analogous to ~/.claude/CLAUDE.md.
{Category: "instructions", HostPath: ".gemini/GEMINI.md", GuestPath: ".gemini/GEMINI.md", Kind: settings.KindFile, Optional: true},
// Cross-tool fallback: users with a single CLAUDE.md on
// the host get context in Gemini too. Gemini's
// `context.fileName` accepts multiple filenames.
{Category: "instructions", HostPath: ".claude/CLAUDE.md", GuestPath: ".gemini/CLAUDE.md", Kind: settings.KindFile, Optional: true},
{Category: "agents", HostPath: ".gemini/agents", GuestPath: ".gemini/agents", Kind: settings.KindDirectory, Optional: true},
{Category: "skills", HostPath: ".gemini/skills", GuestPath: ".gemini/skills", Kind: settings.KindDirectory, Optional: true},
{Category: "skills", HostPath: ".agents/skills", GuestPath: ".agents/skills", Kind: settings.KindDirectory, Optional: true},
{Category: "commands", HostPath: ".gemini/commands", GuestPath: ".gemini/commands", Kind: settings.KindDirectory, Optional: true},
}},
},
}
}

Expand Down
6 changes: 4 additions & 2 deletions internal/infra/agent/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestNewRegistry_ContainsBuiltInAgents(t *testing.T) {
reg := NewRegistry()
agents := reg.List()

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

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

func TestRegistry_Get_BuiltInAgent(t *testing.T) {
Expand All @@ -48,6 +49,7 @@ func TestRegistry_Get_BuiltInAgent(t *testing.T) {
{name: "codex"},
{name: "opencode"},
{name: "hermes"},
{name: "gemini"},
}

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

agents := reg.List()
require.Len(t, agents, 6)
require.Len(t, agents, 7)

for i := 1; i < len(agents); i++ {
assert.True(t, agents[i-1].Name < agents[i].Name,
Expand Down
2 changes: 2 additions & 0 deletions internal/infra/vm/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ func InjectMCPConfig(format domainagent.MCPConfigFormat, gatewayIP string, port
return injectOpenCodeMCP(rootfsPath, gatewayIP, port, chown)
case domainagent.MCPConfigFormatHermes:
return injectHermesMCP(rootfsPath, gatewayIP, port, chown)
case domainagent.MCPConfigFormatGemini:
return injectGeminiMCP(rootfsPath, gatewayIP, port, chown)
default:
return nil
}
Expand Down
22 changes: 22 additions & 0 deletions internal/infra/vm/mcpconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ func injectHermesMCP(rootfsPath, gatewayIP string, port uint16, chown ChownFunc)
}, chown)
}

// --- Gemini CLI ---
// Ref: https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md
// User config lives at ~/.gemini/settings.json (JSON, nested-categories
// format from v0.3.0+). MCP servers go under the top-level "mcpServers"
// key. We use httpUrl (HTTP streaming) since the vmcp proxy speaks
// streamable HTTP at /mcp; "url" would be SSE.

// injectGeminiMCP merges an MCP server entry into ~/.gemini/settings.json,
// preserving any pre-existing keys.
func injectGeminiMCP(rootfsPath, gatewayIP string, port uint16, chown ChownFunc) error {
geminiDir := filepath.Join(rootfsPath, sandboxHome, ".gemini")
if err := mkdirAndChown(geminiDir, chown); err != nil {
return fmt.Errorf("creating ~/.gemini dir: %w", err)
}

return mergeJSONMapEntries(geminiDir, "settings.json", "mcpServers", map[string]any{
"sandbox-tools": map[string]any{
"httpUrl": fmt.Sprintf("http://%s:%d/mcp", gatewayIP, port),
},
}, chown)
}

// --- helpers ---

const (
Expand Down
10 changes: 10 additions & 0 deletions internal/infra/vm/mcpconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ func TestInjectMCPConfig_Dispatch(t *testing.T) {
format: agent.MCPConfigFormatHermes,
wantFile: "home/sandbox/.hermes/config.yaml",
},
{
name: "gemini writes ~/.gemini/settings.json",
format: agent.MCPConfigFormatGemini,
wantFile: "home/sandbox/.gemini/settings.json",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -390,6 +395,11 @@ func TestMCPConfigFilePermissions(t *testing.T) {
inject: func(root string) error { return injectHermesMCP(root, "127.0.0.1", 4483, chown) },
path: "home/sandbox/.hermes/config.yaml",
},
{
name: "gemini",
inject: func(root string) error { return injectGeminiMCP(root, "127.0.0.1", 4483, chown) },
path: "home/sandbox/.gemini/settings.json",
},
}

for _, tt := range tests {
Expand Down
3 changes: 3 additions & 0 deletions pkg/domain/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ const (
// MCPConfigFormatHermes injects a Hermes Agent MCP config file.
MCPConfigFormatHermes MCPConfigFormat = "hermes"

// MCPConfigFormatGemini injects a Gemini CLI MCP config file.
MCPConfigFormatGemini MCPConfigFormat = "gemini"

// MCPConfigFormatNone means no MCP config injection.
MCPConfigFormatNone MCPConfigFormat = "none"
)
Expand Down