diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 6517315c..894728bd 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -64,6 +64,12 @@ jobs: node patch-adapter.mjs echo "AGENT_PI_TEST_RUNTIME=$PWD" >> "$GITHUB_ENV" + - name: Install pinned Gemini reference runtime + working-directory: server/runtime/acp/gemini + run: | + bun install --frozen-lockfile --ignore-scripts + echo "AGENT_GEMINI_TEST_RUNTIME=$PWD" >> "$GITHUB_ENV" + # categorygen's checks (unclassified route, category that isn't control or # platform, classified route with no handler) only run when the generator # does, and its only other caller is `make oapi-generate`, which needs the diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 57db1582..864390b0 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -173,6 +173,8 @@ COPY server/runtime/acp/requirements.txt /opt/kernel-agent/requirements.txt RUN uv pip install --python /opt/kernel-agent/venv/bin/python -r /opt/kernel-agent/requirements.txt COPY server/runtime/acp/pi /opt/kernel-agent/pi RUN cd /opt/kernel-agent/pi && bun install --frozen-lockfile --ignore-scripts && node patch-adapter.mjs +COPY server/runtime/acp/gemini /opt/kernel-agent/gemini +RUN cd /opt/kernel-agent/gemini && bun install --frozen-lockfile --ignore-scripts COPY server/runtime/acp/catalog.json /opt/kernel-agent/catalog.json FROM node:22-bullseye-slim AS node-22 diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 6cb69c77..a71b3077 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -120,6 +120,8 @@ COPY server/runtime/acp/requirements.txt /opt/kernel-agent/requirements.txt RUN uv pip install --python /opt/kernel-agent/venv/bin/python -r /opt/kernel-agent/requirements.txt COPY server/runtime/acp/pi /opt/kernel-agent/pi RUN cd /opt/kernel-agent/pi && bun install --frozen-lockfile --ignore-scripts && node patch-adapter.mjs +COPY server/runtime/acp/gemini /opt/kernel-agent/gemini +RUN cd /opt/kernel-agent/gemini && bun install --frozen-lockfile --ignore-scripts COPY server/runtime/acp/catalog.json /opt/kernel-agent/catalog.json FROM node:22-bullseye-slim AS node-22 diff --git a/server/lib/agentproxy/GEMINI.md b/server/lib/agentproxy/GEMINI.md new file mode 100644 index 00000000..816f2209 --- /dev/null +++ b/server/lib/agentproxy/GEMINI.md @@ -0,0 +1,181 @@ +# Gemini ACP reference — new sessions only + +Both browser images install **@google/gemini-cli 0.58.0**. Its native +`--experimental-acp` implementation runs unpatched behind the existing ACP bridge. +The Gemini runtime has its own frozen Bun lockfile, including optional native +package versions; install scripts are disabled. It uses the reference's Node 22, +Bun 1.4.0, acpremote 1.7.0 and Python/ACP client pins (see [README](README.md)). +Keychain/OAuth authentication is not part of this managed configuration. + +**Reconnect, discovery, load, resume and history replay are unsupported.** Native +`session/list` returns -32601; known-ID load has not been reliable. Gemini still +advertises `loadSession: true` upstream: clients must not interpret that as a +supported Kernel reconnect contract. No capability rewriting, load shim or Kernel +session registry is added. Every new connection must create fresh native sessions. + +## Configure + +Inject `GEMINI_API_KEY` into the browser environment. The packaged `google` +credential binding selects that variable. An operator can instead bind `google` +to `GOOGLE_API_KEY` in the catalog if that is where a Gemini Developer API key is +provisioned. This does **not** select Vertex AI. Keys never go in the PUT body. +This binding contract applies to the configuration API: native ACP `authenticate` +can separately accept client-supplied API keys or gateway metadata in `_meta`, +which the proxy does not inspect or rewrite. + +1. GET `/agent/v1/harnesses/gemini/config` and read its ETag. +2. PUT the configuration with that ETag in `If-Match`. +3. Connect `/agent/v1/acp?harness=gemini`, initialize, authenticate using method + `gemini-api-key`, and call `session/new` with an existing remote `cwd`. + +```json +{ + "launch": { + "model": "gemini-2.5-flash", + "credential": "google", + "trustWorkspace": true + }, + "shared": { + "settings": {"maxSessionTurns": 20}, + "mcpServers": [] + } +} +``` + +`model` is a native Gemini model ID, not a provider-qualified Pi model. +`maxSessionTurns` defaults to 20 when omitted/zero, accepts 1–100, and maps to +native `model.maxSessionTurns`. This is a cumulative lifetime budget of model +round-trips, including tool-continuation turns across all prompts; it never resets. +After exhaustion, later prompts return `max_turn_requests`: create a fresh session. +It is not a wall-clock prompt timeout. ACP owns prompts, permissions, cancellation and session +model/mode controls; the proxy does not transform or retry prompts. + +`trustWorkspace` defaults to false. Native MCP (all transports) requires it to be +true; preparation rejects shared MCP servers otherwise. **Opting in trusts the remote +working directories supplied on this connection**, including their native +`.gemini/settings.json`, context and policy files. Provision only trusted remote +workspaces. Native settings can merge additional workspace MCP definitions with +managed definitions. This is not a sandbox or an isolated-settings guarantee. +Hooks and extensions are disabled by the managed launch/settings, and generic +project `.env` loading is disabled; native `.gemini/.env` behavior in trusted +workspaces still applies. With trust off, native workspace executable settings +are excluded and all MCP is unavailable. Native folder trust can also prevent +operations; this configuration does not bypass that policy silently. + +## Native shared settings and MCP + +The preparer writes a native **system settings** file per revision, selected by +`GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Native user state is under +`/home/kernel/.agents/gemini/home`, outside revisions. System settings take +precedence for managed keys. User settings, native project metadata and native +session files are retained, but retaining files does not imply ACP restoration +support. Existing processes keep their own revision path; new connections use the +last ready revision. Native settings caching and out-of-band filesystem writes +are not reconciled into the desired configuration API. + +`shared.mcpServers` uses the reference's declarative schema, with at most 32 unique +names. Supported native transports: stdio, streamable HTTP (`transport: "http"`) +and SSE (`transport: "sse"`). Example: + +```json +{ + "name": "docs", + "command": "/usr/local/bin/docs-mcp", + "args": ["--read-only"], + "envBindings": {"DOCS_TOKEN": "docs-token"} +} +``` + +For an HTTP server, use `url`, `transport` and optional +`headerBindings: {"Authorization": {"credential": "docs-token", "prefix": "Bearer "}}`. +The operator must declare `docs-token` in the Gemini catalog credentials map. +URLs cannot contain credentials, query parameters or fragments. `$` interpolation +in caller-supplied MCP commands, arguments, URLs and header prefixes is rejected; +use bindings instead. Managed MCP discovery has a 15-second timeout per server. +Preparation does not start MCP servers or authenticate to remote services. + +Only selected provider/MCP source variables are inherited. The launcher copies +bound values into native environment aliases, removes source variable names and +the internal bridge token, then starts Gemini. Persisted configuration/settings +contain only binding names or environment references, never values. Gemini's +native environment redaction blocks the provider key and binding aliases from +implicit subprocess inheritance; explicitly configured MCP destination variables +receive their bound values. MCP servers and native tools are trusted executable +code with browser filesystem access, not a credential-isolation security boundary. +Native settings expansion semantics still apply to bound values. + +Standard ACP `session/new.mcpServers` is passed directly to Gemini. Native Gemini +merges it with shared MCP defaults, overriding by name in that session, including +explicit ACP environment/header values. Nothing is copied into shared settings. +Managed MCP OAuth is not provisioned; use explicit credential bindings instead. +Native authentication errors and permission requests stay on ACP. + +## Supported and unsupported + +| Feature | Contract | +| --- | --- | +| Fresh sessions, prompts, authenticate, tool permissions, cancellation, model/mode controls | Native ACP; no proxy conversation protocol | +| Declarative model, bounded session turns, explicit workspace trust, shared MCP | Supported as above | +| Optimistic revisions, failed preparation retention, existing connections | Existing Preparer/revision manager, unchanged | +| Reconnect, discovery, load, resume, history replay | **Unsupported**, including known-ID restoration | +| Pi extensions, arbitrary settings, hooks, extension installation, OAuth/Vertex/gateway configuration | Unsupported by this preparer; unknown request fields are rejected | + +Ready means schema/materialization and exact installed CLI version checks passed, +not a valid provider key, working remote MCP service or successful future session. +The version probe runs with no provider/MCP credentials in an isolated preparation +directory and a 30-second timeout. Invalid requests return 400/422 without changing +revisions. Failed preparation retains desired and last-ready effective state with +a safe generic error; failed revision directories are removed. Missing If-Match +returns 428, stale/concurrent writes return 409. GET is available during preparation. +Successful revisions remain on disk for existing connections; no automatic GC. + +Each connection owns an independent bridge/Gemini/MCP process tree. Native CLI +relaunch is disabled. Disconnect cleanup uses the unchanged shared bridge teardown +(including process-group escalation), without deleting native state. No runtime +resource, durable proxy output, automatic prompt retry or interrupted-turn recovery. + +## Validation + +```sh +cd server/runtime/acp/gemini +bun install --frozen-lockfile --ignore-scripts +cd ../../.. +AGENT_GEMINI_TEST_RUNTIME="$PWD/runtime/acp/gemini" \ +AGENT_PROXY_TEST_ACPREMOTE=/path/to/acp-venv/bin/acpremote \ + go test -race ./lib/agentproxy ./lib/wsproxy +``` + +Unit tests cover configuration validation, secret-free private revision files, +credential mapping and launch environment isolation, failed preparation retention, +stale/concurrent writes, GET during preparation, restart recovery, preservation of +native state and prior revision paths. Optional installed-runtime tests verify the +pin, native ACP initialization/authentication/fresh sessions, model/mode controls, +idle cancellation and HTTP MCP initialization with a bound header, without sending +provider prompts. They cover both trusted and untrusted fresh sessions; CI enables +these tests. + +The opt-in real-provider gate uses a **fresh disposable image** with `GEMINI_API_KEY` +and `GEMINI_GATE_MCP_TOKEN=fixture-token` injected. Add a test-only catalog binding +`gemini.credentials["gate-mcp"] = "GEMINI_GATE_MCP_TOKEN"`. Run: + +```sh +AGENT_API_URL=http://127.0.0.1:10001 \ + /path/to/acp-venv/bin/python lib/agentproxy/testdata/gemini_gate.py +``` + +This gate refuses existing configuration, makes at most four small +`gemini-2.5-flash` prompts (90 seconds per ACP call, no prompt retry), exercises +native authentication, shared and session-overridden stdio MCP, native permissions, +MCP credential redaction, independent connections, configuration updates while +connected, failed preparation retention, fresh sessions and disconnect cleanup. +It temporarily renames the packaged CLI entrypoint to exercise failed preparation, +restoring it in `finally`. **Never run it against a shared or non-disposable image.** +It outputs a boolean summary, not raw ACP messages or credentials. + +Validated on Linux amd64: headless image build and enabled packaged-image gate; +local race tests including native session controls and HTTP MCP initialization. +The headful runtime stage was also built and verified as 0.58.0, but full headful +image/provider behavior is not independently verified. HTTP MCP tool calls, SSE +MCP interoperability, multimodal input, inference after model switching, +cancellation during active inference, OAuth and platform gateway/TLS integration +are not covered by these tests. Restoration is intentionally not exercised. diff --git a/server/lib/agentproxy/README.md b/server/lib/agentproxy/README.md index 34951f58..1368e044 100644 --- a/server/lib/agentproxy/README.md +++ b/server/lib/agentproxy/README.md @@ -1,7 +1,9 @@ # ACP agents -The browser images bundle a pinned Pi reference implementation. Kernel manages -configuration preparation and connection lifetime; ACP owns conversations. +The browser images bundle a pinned Pi reference implementation and an independent +[Gemini reference](GEMINI.md). **Gemini supports new sessions only, not reconnect, +discovery, load or history replay.** The Pi-specific contract below is unchanged. +Kernel manages configuration preparation and connection lifetime; ACP owns conversations. There is no runtime resource, conversation REST API, prompt journal, automatic prompt retry, or session-ID translation in the WebSocket proxy. @@ -9,7 +11,7 @@ prompt retry, or session-ID translation in the WebSocket proxy. | Endpoint | Behavior | | --- | --- | -| `GET /agent/v1/harnesses` | Returns configured harness names, currently `{"configured":["pi"]}` in the packaged images. This does not mean a model credential is configured. | +| `GET /agent/v1/harnesses` | Returns configured harness names, currently `{"configured":["gemini","pi"]}` in the packaged images. This does not mean a model credential is configured. | | `GET /agent/v1/harnesses/pi/config` | Returns desired/effective configuration, revisions, preparation status and an ETag. | | `PUT /agent/v1/harnesses/pi/config` | Validates, installs and checks the requested configuration, then activates it. Requires `If-Match` from GET. | | WebSocket `GET /agent/v1/acp?harness=pi` | Starts a connection-owned `acpremote expose` bridge and Pi adapter using the last ready launch definition. | @@ -200,11 +202,11 @@ credential bindings and an optional npm `registry`. The default state directory is `/home/kernel/.agents/pi`. The original trusted `harnesses` launch catalog remains supported for separately -provisioned agents. Only Pi has a packaged declarative preparer here. The -`Preparer` interface and common revision manager are the implementation boundary -for subsequent harnesses; their native configuration support must be explicit. -Gemini's future integration excludes reconnect/discovery/load until its ACP -implementation satisfies that protocol gate. +provisioned agents. Pi and Gemini have separate packaged declarative preparers. +The `Preparer` interface and common revision manager remain the implementation +boundary for subsequent harnesses; their native configuration support must be +explicit. See [Gemini's contract and validation](GEMINI.md) for its settings, +authentication and new-sessions-only limitation. ## Validation diff --git a/server/lib/agentproxy/config.go b/server/lib/agentproxy/config.go index 40d3ebf6..fdaa59e3 100644 --- a/server/lib/agentproxy/config.go +++ b/server/lib/agentproxy/config.go @@ -19,6 +19,7 @@ type Config struct { MaxConnections int `json:"maxConnections"` Harnesses map[string]Harness `json:"harnesses"` Pi *PiOptions `json:"pi,omitempty"` + Gemini *GeminiOptions `json:"gemini,omitempty"` } type Harness struct { @@ -70,7 +71,12 @@ func (c Config) validate() error { return err } } - if len(c.Harnesses) == 0 && c.Pi == nil { + if c.Gemini != nil { + if err := c.Gemini.validate(); err != nil { + return err + } + } + if len(c.Harnesses) == 0 && c.Pi == nil && c.Gemini == nil { return errors.New("at least one harness is required") } for name, harness := range c.Harnesses { diff --git a/server/lib/agentproxy/gemini.go b/server/lib/agentproxy/gemini.go new file mode 100644 index 00000000..d8fd46ab --- /dev/null +++ b/server/lib/agentproxy/gemini.go @@ -0,0 +1,218 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "syscall" + "time" +) + +const geminiVersion = "0.58.0" + +type GeminiOptions struct { + StateDir string `json:"stateDir"` + RuntimeDir string `json:"runtimeDir"` + Node string `json:"node"` + Credentials map[string]string `json:"credentials"` +} + +type GeminiConfiguration struct { + Launch GeminiLaunch `json:"launch"` + Shared GeminiShared `json:"shared"` +} +type GeminiLaunch struct { + Model string `json:"model"` + Credential string `json:"credential"` + TrustWorkspace bool `json:"trustWorkspace"` +} +type GeminiShared struct { + Settings GeminiSettings `json:"settings"` + MCPServers []ManagedMCPServer `json:"mcpServers"` +} +type GeminiSettings struct { + MaxSessionTurns int `json:"maxSessionTurns"` +} + +var geminiEnvName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +var geminiModel = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) + +func (p GeminiOptions) validate() error { + if !filepath.IsAbs(p.StateDir) || !filepath.IsAbs(p.RuntimeDir) || !filepath.IsAbs(p.Node) { + return errors.New("gemini paths must be absolute") + } + for name, source := range p.Credentials { + if name == "" || !geminiEnvName.MatchString(source) || !validEnvName(source) { + return errors.New("invalid gemini credential binding") + } + } + return nil +} + +func (p GeminiOptions) validateDesired(c GeminiConfiguration) error { + if !geminiModel.MatchString(c.Launch.Model) { + return errors.New("a native Gemini model ID is required") + } + if c.Shared.Settings.MaxSessionTurns < 1 || c.Shared.Settings.MaxSessionTurns > 100 { + return errors.New("maxSessionTurns must be between 1 and 100") + } + bindings, err := validateMCPServers(c.Shared.MCPServers) + if err != nil { + return err + } + if len(c.Shared.MCPServers) != 0 && !c.Launch.TrustWorkspace { + return errors.New("Gemini MCP requires explicit trustWorkspace") + } + for _, s := range c.Shared.MCPServers { + if s.Name == "__proto__" || s.Name == "constructor" || s.Name == "prototype" { + return errors.New("reserved MCP server name") + } + for name := range s.EnvBindings { + if !geminiEnvName.MatchString(name) { + return errors.New("invalid MCP environment name") + } + } + // Gemini expands settings strings natively; only managed bindings may interpolate. + values := append([]string{s.Command, s.URL}, s.Args...) + for _, header := range s.HeaderBindings { + values = append(values, header.Prefix) + } + for _, value := range values { + if strings.ContainsAny(value, "$\x00") { + return errors.New("MCP settings must not contain environment interpolation or NUL") + } + } + } + for _, binding := range append(bindings, c.Launch.Credential) { + source, ok := p.Credentials[binding] + if !ok || os.Getenv(source) == "" { + return errors.New("credential binding is unavailable") + } + } + return nil +} + +func (p GeminiOptions) Prepare(ctx context.Context, dir string, desired json.RawMessage) (Harness, error) { + var c GeminiConfiguration + if err := json.Unmarshal(desired, &c); err != nil { + return Harness{}, err + } + if err := p.validateDesired(c); err != nil { + return Harness{}, err + } + if err := atomicWrite(filepath.Join(dir, "config.json"), desired); err != nil { + return Harness{}, err + } + + // Generated settings contain environment references, never credential values. + refs := make(map[string]string) + aliases := make(map[string]string) + alias := func(binding string) string { + source := p.Credentials[binding] + name, ok := aliases[source] + if !ok { + name = fmt.Sprintf("KERNEL_GEMINI_SECRET_%d", len(refs)) + refs[name] = source + aliases[source] = name + } + return "${" + name + "}" + } + servers := make(map[string]any) + for _, s := range c.Shared.MCPServers { + server := map[string]any{"trust": false, "timeout": 15000} + if s.Command != "" { + env := make(map[string]string) + for name, binding := range s.EnvBindings { + env[name] = alias(binding) + } + server["command"], server["env"] = s.Command, env + if len(s.Args) != 0 { + server["args"] = s.Args + } + } else { + headers := make(map[string]string) + for name, header := range s.HeaderBindings { + headers[name] = header.Prefix + alias(header.Credential) + } + server["url"], server["type"], server["headers"] = s.URL, s.Transport, headers + server["oauth"] = map[string]bool{"enabled": false} + } + servers[s.Name] = server + } + blocked := []string{"GEMINI_API_KEY"} + for name := range refs { + blocked = append(blocked, name) + } + sort.Strings(blocked) + settings := map[string]any{ + "model": map[string]any{"name": c.Launch.Model, "maxSessionTurns": c.Shared.Settings.MaxSessionTurns}, + "mcpServers": servers, + "general": map[string]bool{"enableAutoUpdate": false, "enableAutoUpdateNotification": false}, + "privacy": map[string]bool{"usageStatisticsEnabled": false}, + "telemetry": map[string]bool{"enabled": false}, + "hooksConfig": map[string]bool{"enabled": false}, + "advanced": map[string]bool{"ignoreLocalEnv": true}, + "security": map[string]any{ + "auth": map[string]string{"selectedType": "gemini-api-key"}, + "environmentVariableRedaction": map[string]any{"enabled": true, "blocked": blocked}, + }, + } + data, err := json.Marshal(settings) + if err != nil { + return Harness{}, err + } + if err = atomicWrite(filepath.Join(dir, "settings.json"), data); err != nil { + return Harness{}, err + } + if err = atomicWrite(filepath.Join(dir, "system-defaults.json"), []byte("{}")); err != nil { + return Harness{}, err + } + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, p.Node, filepath.Join(p.RuntimeDir, "node_modules/@google/gemini-cli/bundle/gemini.js"), "--version") + command.Dir = dir + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GEMINI_CLI_HOME=" + dir, "GEMINI_CLI_SYSTEM_SETTINGS_PATH=" + filepath.Join(dir, "settings.json"), "GEMINI_CLI_SYSTEM_DEFAULTS_PATH=" + filepath.Join(dir, "system-defaults.json")} + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } + command.WaitDelay = time.Second + output, err := command.Output() + if command.Process != nil { + _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + } + if err != nil || strings.TrimSpace(string(output)) != geminiVersion { + return Harness{}, errors.New("pinned Gemini runtime validation failed") + } + if err = os.MkdirAll(filepath.Join(p.StateDir, "home"), 0700); err != nil { + return Harness{}, err + } + bindings, _ := json.Marshal(refs) + sources := map[string]bool{p.Credentials[c.Launch.Credential]: true} + for _, source := range refs { + sources[source] = true + } + inherited := make([]string, 0, len(sources)) + for source := range sources { + inherited = append(inherited, source) + } + sort.Strings(inherited) + return Harness{ + Command: p.Node, Args: []string{filepath.Join(p.RuntimeDir, "launch.mjs"), filepath.Join(dir, "config.json")}, Cwd: p.StateDir, + Env: map[string]string{ + "HOME": filepath.Join(p.StateDir, "home"), + "GEMINI_CLI_HOME": filepath.Join(p.StateDir, "home"), + "GEMINI_CLI_SYSTEM_SETTINGS_PATH": filepath.Join(dir, "settings.json"), + "GEMINI_CLI_SYSTEM_DEFAULTS_PATH": filepath.Join(dir, "system-defaults.json"), + "GEMINI_CLI_TRUST_WORKSPACE": fmt.Sprint(c.Launch.TrustWorkspace), + "KERNEL_GEMINI_BINDINGS": string(bindings), + "KERNEL_GEMINI_PROVIDER_SOURCE": p.Credentials[c.Launch.Credential], + }, InheritEnv: inherited, + }, nil +} diff --git a/server/lib/agentproxy/gemini_http.go b/server/lib/agentproxy/gemini_http.go new file mode 100644 index 00000000..d4fb9d58 --- /dev/null +++ b/server/lib/agentproxy/gemini_http.go @@ -0,0 +1,65 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +func (h *Handler) geminiConfiguration(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + expected := r.Header.Get("If-Match") + if expected == "" { + http.Error(w, "If-Match revision required", http.StatusPreconditionRequired) + return + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + var desired GeminiConfiguration + if err := decoder.Decode(&desired); err != nil { + http.Error(w, "invalid gemini configuration", http.StatusBadRequest) + return + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + http.Error(w, "expected one configuration", http.StatusBadRequest) + return + } + if desired.Shared.Settings.MaxSessionTurns == 0 { + desired.Shared.Settings.MaxSessionTurns = 20 + } + if err := h.config.Gemini.validateDesired(desired); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + // Normalize empty arrays so configuration responses never serialize them as null. + if desired.Shared.MCPServers == nil { + desired.Shared.MCPServers = make([]ManagedMCPServer, 0) + } + data, _ := json.Marshal(desired) + ctx, cancel := context.WithCancel(r.Context()) + stop := context.AfterFunc(h.ctx, cancel) + defer stop() + defer cancel() + if err := h.gemini.apply(ctx, strings.Trim(expected, "\""), data); err != nil { + if errors.Is(err, errConfigurationConflict) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + http.Error(w, "configuration preparation failed; inspect GET configuration status", http.StatusUnprocessableEntity) + return + } + } else if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET, PUT") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + state := h.gemini.snapshot() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", "\""+state.Revision+"\"") + _ = json.NewEncoder(w).Encode(state) +} diff --git a/server/lib/agentproxy/gemini_runtime_test.go b/server/lib/agentproxy/gemini_runtime_test.go new file mode 100644 index 00000000..88a9fa48 --- /dev/null +++ b/server/lib/agentproxy/gemini_runtime_test.go @@ -0,0 +1,218 @@ +package agentproxy + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" +) + +func TestGeminiLaunchCredentialIsolation(t *testing.T) { + p := geminiTestOptions(t) + source, err := os.ReadFile("../../runtime/acp/gemini/launch.mjs") + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(p.RuntimeDir, "launch.mjs"), source, 0600); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + desired, _ := json.Marshal(geminiTestConfiguration()) + launch, err := p.Prepare(context.Background(), dir, desired) + if err != nil { + t.Fatal(err) + } + // Verify values inside the fixture without emitting them on stdout/stderr. + fixture := `const e=process.env; +const ok=e.GEMINI_API_KEY==="private-provider-value" && e.KERNEL_GEMINI_SECRET_0==="private-mcp-value" + && !e.GEMINI_TEST_PROVIDER && !e.GEMINI_TEST_MCP && !e.KERNEL_GEMINI_BINDINGS + && !e.KERNEL_GEMINI_PROVIDER_SOURCE && !Object.values(e).includes("bridge-private-token"); +console.log(JSON.stringify({ok,args:process.argv.slice(2)}));` + if err = os.WriteFile(filepath.Join(p.RuntimeDir, "node_modules/@google/gemini-cli/bundle/gemini.js"), []byte(fixture), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, launch.Command, launch.Args...) + cmd.Dir = launch.Cwd + cmd.Env, err = launch.environment("bridge-private-token") + if err != nil { + t.Fatal(err) + } + output, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(output), `"ok":true`) || !strings.Contains(string(output), `"--experimental-acp","--model","gemini-2.5-flash","--extensions","none"`) { + t.Fatal(string(output)) + } +} + +func TestGeminiNativeFreshSessionControls(t *testing.T) { + runtime := os.Getenv("AGENT_GEMINI_TEST_RUNTIME") + if runtime == "" { + t.Skip("set AGENT_GEMINI_TEST_RUNTIME for native ACP initialization") + } + for _, trust := range []bool{false, true} { + t.Run(fmt.Sprintf("trust=%t", trust), func(t *testing.T) { + testGeminiNativeFreshSessionControls(t, runtime, trust) + }) + } +} + +func testGeminiNativeFreshSessionControls(t *testing.T, runtime string, trust bool) { + p := geminiTestOptions(t) + p.RuntimeDir = runtime + var httpMCPInitialized atomic.Bool + mcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if r.Header.Get("Authorization") != "Bearer private-mcp-value" { + t.Error("native HTTP MCP credential binding was not resolved") + w.WriteHeader(http.StatusUnauthorized) + return + } + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if len(request.ID) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + result := map[string]any{"tools": []any{}} + if request.Method == "initialize" { + httpMCPInitialized.Store(true) + result = map[string]any{"protocolVersion": request.Params.ProtocolVersion, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]string{"name": "fixture", "version": "1"}} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": result}) + })) + defer mcp.Close() + c := geminiTestConfiguration() + c.Shared.MCPServers = []ManagedMCPServer{{Name: "http-fixture", URL: mcp.URL, Transport: "http", HeaderBindings: map[string]CredentialHeader{"Authorization": {Credential: "docs", Prefix: "Bearer "}}}} + c.Launch.TrustWorkspace = trust + if !trust { + c.Shared.MCPServers = make([]ManagedMCPServer, 0) + } + desired, _ := json.Marshal(c) + launch, err := p.Prepare(context.Background(), t.TempDir(), desired) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, launch.Command, launch.Args...) + cmd.Dir = launch.Cwd + cmd.Env, err = launch.environment("bridge-private-token") + if err != nil { + t.Fatal(err) + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = time.Second + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { cancel(); _ = cmd.Wait() }() + scanner := bufio.NewScanner(stdout) + seq := 0 + call := func(method string, params any) json.RawMessage { + t.Helper() + seq++ + data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": seq, "method": method, "params": params}) + if err != nil { + t.Fatal(err) + } + if _, err = stdin.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + for scanner.Scan() { + var response struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(scanner.Bytes(), &response) != nil || response.ID != seq { + continue + } + if len(response.Error) != 0 { + t.Fatalf("native %s failed (error output intentionally omitted)", method) + } + return response.Result + } + t.Fatalf("native %s did not respond: %v", method, scanner.Err()) + return nil + } + initialized := call("initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}}) + var info struct { + AgentInfo struct { + Version string `json:"version"` + } `json:"agentInfo"` + AuthMethods []struct { + ID string `json:"id"` + } `json:"authMethods"` + } + if err = json.Unmarshal(initialized, &info); err != nil { + t.Fatal(err) + } + if info.AgentInfo.Version != geminiVersion { + t.Fatal("incorrect native agent version") + } + found := false + for _, method := range info.AuthMethods { + if method.ID == "gemini-api-key" { + found = true + } + } + if !found { + t.Fatal("native API key authentication not advertised") + } + // These controls configure a session with a dummy key; none sends a prompt. + call("authenticate", map[string]string{"methodId": "gemini-api-key"}) + created := call("session/new", map[string]any{"cwd": p.StateDir, "mcpServers": []any{}}) + var session struct { + SessionID string `json:"sessionId"` + } + if err = json.Unmarshal(created, &session); err != nil || session.SessionID == "" { + t.Fatal("missing native session ID") + } + if trust && !httpMCPInitialized.Load() { + t.Fatal("native HTTP MCP did not initialize") + } + call("session/set_model", map[string]string{"sessionId": session.SessionID, "modelId": "gemini-2.5-flash"}) + call("session/set_mode", map[string]string{"sessionId": session.SessionID, "modeId": "default"}) + // ACP cancellation is a notification, not a request with a response. + cancelMessage, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]string{"sessionId": session.SessionID}}) + if _, err = stdin.Write(append(cancelMessage, '\n')); err != nil { + t.Fatal(err) + } + call("session/set_mode", map[string]string{"sessionId": session.SessionID, "modeId": "default"}) +} diff --git a/server/lib/agentproxy/gemini_test.go b/server/lib/agentproxy/gemini_test.go new file mode 100644 index 00000000..b83c3bce --- /dev/null +++ b/server/lib/agentproxy/gemini_test.go @@ -0,0 +1,264 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func geminiTestOptions(t *testing.T) GeminiOptions { + t.Helper() + t.Setenv("GEMINI_TEST_PROVIDER", "private-provider-value") + t.Setenv("GEMINI_TEST_MCP", "private-mcp-value") + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node required for Gemini runtime preparation") + } + runtime := t.TempDir() + path := filepath.Join(runtime, "node_modules/@google/gemini-cli/bundle") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + // Preparation must not inherit either provider or MCP credentials. + source := `if (Object.values(process.env).some(v => v.includes("private-"))) process.exit(1); console.log("0.58.0");` + if err := os.WriteFile(filepath.Join(path, "gemini.js"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + return GeminiOptions{StateDir: t.TempDir(), RuntimeDir: runtime, Node: node, Credentials: map[string]string{"google": "GEMINI_TEST_PROVIDER", "docs": "GEMINI_TEST_MCP"}} +} +func geminiTestConfiguration() GeminiConfiguration { + return GeminiConfiguration{Launch: GeminiLaunch{Model: "gemini-2.5-flash", Credential: "google", TrustWorkspace: true}, Shared: GeminiShared{Settings: GeminiSettings{MaxSessionTurns: 8}, MCPServers: []ManagedMCPServer{ + {Name: "docs", Command: "/bin/true", EnvBindings: map[string]string{"DOCS_TOKEN": "docs"}}, + {Name: "remote", URL: "https://example.com/mcp", Transport: "http", HeaderBindings: map[string]CredentialHeader{"Authorization": {Credential: "docs", Prefix: "Bearer "}}}, + }}} +} + +func TestGeminiValidation(t *testing.T) { + p := geminiTestOptions(t) + cases := map[string]func(*GeminiConfiguration){ + "missing model": func(c *GeminiConfiguration) { c.Launch.Model = "" }, + "model flag": func(c *GeminiConfiguration) { c.Launch.Model = "--yolo" }, + "model interpolation": func(c *GeminiConfiguration) { c.Launch.Model = "$HOME" }, + "unknown credential": func(c *GeminiConfiguration) { c.Launch.Credential = "absent" }, + "unbounded turns": func(c *GeminiConfiguration) { c.Shared.Settings.MaxSessionTurns = -1 }, + "too many turns": func(c *GeminiConfiguration) { c.Shared.Settings.MaxSessionTurns = 101 }, + "untrusted stdio": func(c *GeminiConfiguration) { c.Launch.TrustWorkspace = false }, + "untrusted HTTP": func(c *GeminiConfiguration) { + c.Launch.TrustWorkspace = false + c.Shared.MCPServers = c.Shared.MCPServers[1:] + }, + "unknown MCP credential": func(c *GeminiConfiguration) { c.Shared.MCPServers[0].EnvBindings["DOCS_TOKEN"] = "absent" }, + "duplicate MCP": func(c *GeminiConfiguration) { c.Shared.MCPServers[1].Name = "docs" }, + "prototype MCP": func(c *GeminiConfiguration) { c.Shared.MCPServers[0].Name = "__proto__" }, + "inline credential": func(c *GeminiConfiguration) { c.Shared.MCPServers[1].URL = "https://user:pass@example.com/mcp" }, + "query credential": func(c *GeminiConfiguration) { c.Shared.MCPServers[1].URL = "https://example.com/mcp?key=secret" }, + "interpolated arg": func(c *GeminiConfiguration) { c.Shared.MCPServers[0].Args = []string{"${GEMINI_API_KEY}"} }, + "interpolated header": func(c *GeminiConfiguration) { + c.Shared.MCPServers[1].HeaderBindings["Authorization"] = CredentialHeader{Credential: "docs", Prefix: "$HOME"} + }, + } + if err := p.validateDesired(geminiTestConfiguration()); err != nil { + t.Fatal(err) + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + c := geminiTestConfiguration() + mutate(&c) + if p.validateDesired(c) == nil { + t.Fatal("accepted invalid configuration") + } + }) + } + t.Setenv("GEMINI_TEST_PROVIDER", "") + if p.validateDesired(geminiTestConfiguration()) == nil { + t.Fatal("accepted empty binding") + } +} + +func TestGeminiPreparationAndRecovery(t *testing.T) { + p := geminiTestOptions(t) + m, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + desired, _ := json.Marshal(geminiTestConfiguration()) + if err = m.apply(context.Background(), "0", desired); err != nil { + t.Fatal(err) + } + ready := m.snapshot() + launch, _ := m.preparedLaunch() + settings, err := os.ReadFile(filepath.Join(p.StateDir, "current/settings.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(settings), "${KERNEL_GEMINI_SECRET_0}") || !strings.Contains(string(settings), `"selectedType":"gemini-api-key"`) { + t.Fatal(string(settings)) + } + for _, source := range []string{"GEMINI_TEST_PROVIDER", "GEMINI_TEST_MCP"} { + if strings.Contains(string(settings), source) { + t.Fatal("source names leaked to settings") + } + } + // Native mutable state must not be under the replaceable revision. + marker := filepath.Join(launch.Env["HOME"], "native-marker") + if err = os.WriteFile(marker, []byte("retain"), 0600); err != nil { + t.Fatal(err) + } + if err = m.apply(context.Background(), ready.Revision, desired); err != nil { + t.Fatal(err) + } + latest := m.snapshot() + if _, err = os.Stat(marker); err != nil { + t.Fatal("native state was replaced", err) + } + if _, err = os.Stat(launch.Env["GEMINI_CLI_SYSTEM_SETTINGS_PATH"]); err != nil { + t.Fatal("old launch revision removed", err) + } + broken := p + broken.Node = "/missing/node" + m.preparer = broken + if err = m.apply(context.Background(), latest.Revision, desired); err == nil { + t.Fatal("broken preparation activated") + } + failed := m.snapshot() + if failed.Status != "failed" || failed.EffectiveRevision != latest.Revision || failed.Revision == latest.Revision { + t.Fatal(failed) + } + if _, err = os.Stat(filepath.Join(p.StateDir, "revisions", failed.Revision)); !os.IsNotExist(err) { + t.Fatal("failed directory retained", err) + } + recovered, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + if recovered.snapshot().EffectiveRevision != latest.Revision || recovered.snapshot().Status != "failed" { + t.Fatal(recovered.snapshot()) + } + if err = recovered.apply(context.Background(), ready.Revision, desired); !errors.Is(err, errConfigurationConflict) { + t.Fatal("stale revision accepted", err) + } + // All persisted files and public responses are free of credential values. + err = filepath.Walk(p.StateDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), "private-provider-value") || strings.Contains(string(data), "private-mcp-value") { + t.Errorf("credential in %s", path) + } + if info.Mode().Perm()&0077 != 0 { + t.Errorf("nonprivate file %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestGeminiConfigurationHTTP(t *testing.T) { + p := geminiTestOptions(t) + h, err := New(context.Background(), Config{ACPRemote: "/bin/acpremote", MaxConnections: 2, Gemini: &p}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + t.Fatal(err) + } + request := func(method, path, body, etag string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if etag != "" { + r.Header.Set("If-Match", etag) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + path := "/agent/v1/harnesses/gemini/config" + if w := request("GET", "/agent/v1/harnesses", "", ""); !strings.Contains(w.Body.String(), `["gemini"]`) { + t.Fatal(w.Body.String()) + } + if w := request("GET", "/agent/v1/acp?harness=gemini", "", ""); w.Code != 409 { + t.Fatal(w.Code) + } + body := `{"launch":{"model":"gemini-2.5-flash","credential":"google"},"shared":{}}` + if w := request("PUT", path, body, ""); w.Code != 428 { + t.Fatal(w.Code) + } + for _, bad := range []string{`{"launch":{"provider":"google"}}`, `{"shared":{"extensions":[]}}`, body + body} { + if w := request("PUT", path, bad, `"0"`); w.Code != 400 { + t.Fatal(w.Code, w.Body.String()) + } + } + w := request("PUT", path, body, `"0"`) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"mcpServers":[]`) || !strings.Contains(w.Body.String(), `"maxSessionTurns":20`) { + t.Fatal(w.Body.String()) + } + if strings.Contains(w.Body.String(), "private-") || strings.Contains(w.Body.String(), "GEMINI_TEST_PROVIDER") { + t.Fatal("public credential leak") + } + if stale := request("PUT", path, body, `"0"`); stale.Code != 409 { + t.Fatal(stale.Code) + } + if w := request("DELETE", path, "", ""); w.Code != 405 { + t.Fatal(w.Code) + } + // GET stays available while one PUT prepares; a competing write returns 409. + started, release := make(chan struct{}), make(chan struct{}) + h.gemini.preparer = prepareFunc(func(ctx context.Context, dir string, desired json.RawMessage) (Harness, error) { + close(started) + <-release + return Harness{}, errors.New("private-provider-value") + }) + done := make(chan *httptest.ResponseRecorder, 1) + etag := w.Header().Get("ETag") + go func() { done <- request("PUT", path, body, etag) }() + <-started + state := request("GET", path, "", "") + if !strings.Contains(state.Body.String(), `"status":"preparing"`) { + t.Fatal(state.Body.String()) + } + conflict := request("PUT", path, body, state.Header().Get("ETag")) + close(release) + if conflict.Code != http.StatusConflict { + t.Fatal(conflict.Code) + } + if result := <-done; result.Code != 422 || strings.Contains(result.Body.String(), "private-") { + t.Fatal(result.Code, result.Body.String()) + } + if result := request("GET", path, "", ""); !strings.Contains(result.Body.String(), `"status":"failed"`) || strings.Contains(result.Body.String(), "private-") { + t.Fatal(result.Body.String()) + } +} + +func TestGeminiPinnedRuntimePreparation(t *testing.T) { + runtime := os.Getenv("AGENT_GEMINI_TEST_RUNTIME") + if runtime == "" { + t.Skip("set AGENT_GEMINI_TEST_RUNTIME to the installed pinned Gemini runtime") + } + p := geminiTestOptions(t) + p.RuntimeDir = runtime + m, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + desired, _ := json.Marshal(geminiTestConfiguration()) + if err = m.apply(context.Background(), "0", desired); err != nil { + t.Fatal(err) + } +} diff --git a/server/lib/agentproxy/handler.go b/server/lib/agentproxy/handler.go index d294cda2..ad593ac7 100644 --- a/server/lib/agentproxy/handler.go +++ b/server/lib/agentproxy/handler.go @@ -21,6 +21,7 @@ type Handler struct { registry *wsdrain.Registry slots chan struct{} pi *configurationManager + gemini *configurationManager } func New(ctx context.Context, config Config, logger *slog.Logger, registry *wsdrain.Registry) (*Handler, error) { @@ -35,6 +36,13 @@ func New(ctx context.Context, config Config, logger *slog.Logger, registry *wsdr return nil, err } } + if config.Gemini != nil { + var err error + h.gemini, err = newConfigurationManager(config.Gemini.StateDir, *config.Gemini) + if err != nil { + return nil, err + } + } return h, nil } @@ -45,8 +53,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.pi != nil { names = append(names, "pi") } + if h.gemini != nil { + names = append(names, "gemini") + } for name := range h.config.Harnesses { - if name == "pi" && h.pi != nil { + if (name == "pi" && h.pi != nil) || (name == "gemini" && h.gemini != nil) { continue } names = append(names, name) @@ -58,6 +69,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }{names}) case r.URL.Path == "/agent/v1/harnesses/pi/config" && h.pi != nil: h.piConfiguration(w, r) + case r.URL.Path == "/agent/v1/harnesses/gemini/config" && h.gemini != nil: + h.geminiConfiguration(w, r) case r.Method == http.MethodGet && r.URL.Path == "/agent/v1/acp": h.connect(w, r) default: @@ -75,6 +88,13 @@ func (h *Handler) connect(w http.ResponseWriter, r *http.Request) { return } } + if name == "gemini" && h.gemini != nil { + harness, ok = h.gemini.preparedLaunch() + if !ok { + http.Error(w, "gemini configuration is not ready", http.StatusConflict) + return + } + } if !ok { http.Error(w, "harness is not configured", http.StatusNotFound) return diff --git a/server/lib/agentproxy/testdata/gemini_gate.py b/server/lib/agentproxy/testdata/gemini_gate.py new file mode 100644 index 00000000..9962c787 --- /dev/null +++ b/server/lib/agentproxy/testdata/gemini_gate.py @@ -0,0 +1,190 @@ +"""Opt-in Gemini 0.58.0 packaged-image gate: fresh sessions only, no restoration. +Run with AGENT_API_URL and the pinned runtime/acp/requirements.txt Python environment. +The disposable image must have GEMINI_API_KEY and GEMINI_GATE_MCP_TOKEN=fixture-token. +At most four bounded prompts; no prompt retries. No raw ACP output is persisted. +""" + +import asyncio +import base64 +import json +import os +import pathlib +import urllib.error +import urllib.request +import uuid + +import websockets + +base = os.environ["AGENT_API_URL"].rstrip("/") +workspace = "/tmp/gemini-gate-" + uuid.uuid4().hex +config_path = "/agent/v1/harnesses/gemini/config" +python = "/opt/kernel-agent/venv/bin/python" + + +def http(method, path, data=None, revision=None): + headers = {"Content-Type": "application/json"} + if revision is not None: + headers["If-Match"] = '"' + revision + '"' + request = urllib.request.Request( + base + path, data=json.dumps(data).encode() if data is not None else None, + method=method, headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + + +def remote(code): + status, response = http("POST", "/process/exec", {"command": python, "args": ["-c", code]}) + assert status == 200 and response["exit_code"] == 0, "remote fixture command failed" + return base64.b64decode(response["stdout_b64"]).decode() + + +def pids(include_mcp=False): + return set(json.loads(remote("import pathlib,json\ninclude_mcp=" + repr(include_mcp) + "\n" + """ +pids=[] +for path in pathlib.Path('/proc').iterdir(): + if not path.name.isdigit():continue + try:args=(path/'cmdline').read_bytes().split(bytes([0])) + except (FileNotFoundError,ProcessLookupError,PermissionError):continue + if b'/opt/kernel-agent/gemini/node_modules/@google/gemini-cli/bundle/gemini.js' in args or (include_mcp and any(a.startswith(b'/tmp/gemini-gate-') and a.endswith(b'/mcp.py') for a in args)):pids.append(path.name) +print(json.dumps(pids)) +"""))) + + +class Client: + def __init__(self): + self.seq = 0 + self.permissions = 0 + + async def open(self): + self.ws = await websockets.connect("ws" + base[4:] + "/agent/v1/acp?harness=gemini", max_size=1 << 20) + try: + result, _ = await self.call("initialize", {"protocolVersion": 1, "clientCapabilities": {}}) + assert result["agentInfo"]["version"] == "0.58.0" + assert any(m["id"] == "gemini-api-key" for m in result["authMethods"]) + await self.call("authenticate", {"methodId": "gemini-api-key"}) + except BaseException: + await self.ws.close() + raise + return self + + async def close(self): + await self.ws.close() + + async def call(self, method, params): + self.seq += 1 + await self.ws.send(json.dumps({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params})) + updates = [] + async with asyncio.timeout(90): + while True: + message = json.loads(await self.ws.recv()) + if message.get("method") == "session/request_permission": + self.permissions += 1 + option = next(o for o in message["params"]["options"] if o["kind"] == "allow_once") + await self.ws.send(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": { + "outcome": {"outcome": "selected", "optionId": option["optionId"]} + }})) + elif message.get("id") == self.seq and ("result" in message or "error" in message): + assert "error" not in message, f"native {method} failed (error output intentionally omitted)" + return message["result"], updates + else: + updates.append(message) + + async def prompt(self, sid, text): + _, updates = await self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + return "".join(u.get("params", {}).get("update", {}).get("content", {}).get("text", "") + for u in updates if u.get("params", {}).get("update", {}).get("sessionUpdate") == "agent_message_chunk") + + +async def main(): + status, initial = http("GET", config_path) + assert status == 200 and initial["status"] == "unconfigured", "use a fresh disposable image" + assert not pids(), "refusing a browser with existing Gemini processes" + # Record only boolean isolation evidence, never an environment dump. + fixture = pathlib.Path(__file__).with_name("mcp_checkpoint.py").read_text() + fixture = "import os\nassert not os.getenv('GEMINI_API_KEY')\nassert not any(k.startswith('KERNEL_GEMINI_SECRET_') for k in os.environ)\nassert os.getenv('DOCS_TOKEN') == 'fixture-token'\n" + fixture + encoded = base64.b64encode(fixture.encode()).decode() + remote(f"import pathlib,base64; p=pathlib.Path('{workspace}');p.mkdir();(p/'mcp.py').write_bytes(base64.b64decode('{encoded}'))") + shared = {"name": "checkpoint", "command": python, + "args": [workspace + "/mcp.py", "shared-marker", workspace + "/calls"], + "envBindings": {"DOCS_TOKEN": "gate-mcp"}} + desired = {"launch": {"model": "gemini-2.5-flash", "credential": "google", "trustWorkspace": True}, + "shared": {"settings": {"maxSessionTurns": 6}, "mcpServers": [shared]}} + assert http("PUT", config_path, desired)[0] == 428 + status, ready = http("PUT", config_path, desired, initial["revision"]) + assert status == 200, "initial preparation failed" + assert http("PUT", config_path, desired, initial["revision"])[0] == 409 + invalid = json.loads(json.dumps(desired)) + invalid["shared"]["settings"]["maxSessionTurns"] = -1 + assert http("PUT", config_path, invalid, ready["revision"])[0] == 422 + clients = [] + try: + first = await Client().open() + clients.append(first) + session, _ = await first.call("session/new", {"cwd": workspace, "mcpServers": []}) + old = pids() + old_tree = pids(include_mcp=True) + assert len(old) == 1 and len(old_tree) > 1 + second = await Client().open() + clients.append(second) + override = {"name": "checkpoint", "command": python, + "args": [workspace + "/mcp.py", "session-marker", workspace + "/calls"], + "env": [{"name": "DOCS_TOKEN", "value": "fixture-token"}]} + other, _ = await second.call("session/new", {"cwd": workspace, "mcpServers": [override]}) + assert other["sessionId"] != session["sessionId"] and len(pids()) == 2 + for client, sid, marker in [(first, session["sessionId"], "shared-marker"), (second, other["sessionId"], "session-marker")]: + text = await client.prompt(sid, "Call the checkpoint MCP tool exactly once and return its output. Do not use any other tools.") + assert marker in text, "MCP marker missing from native response" + assert first.permissions + second.permissions > 0, "no native permission requests observed" + calls = remote(f"from pathlib import Path;print(Path('{workspace}/calls').read_text())") + assert "shared-marker" in calls and "session-marker" in calls + active = pids() + updated = json.loads(json.dumps(desired)) + updated["shared"]["settings"]["maxSessionTurns"] = 7 + status, latest = await asyncio.to_thread(http, "PUT", config_path, updated, ready["revision"]) + assert status == 200 and pids() == active + # Break only the packaged executable temporarily to test preparation failure retention. + bundle = "/opt/kernel-agent/gemini/node_modules/@google/gemini-cli/bundle/gemini.js" + remote(f"from pathlib import Path;p=Path('{bundle}');p.rename(str(p)+'.gate-backup')") + try: + assert (await asyncio.to_thread(http, "PUT", config_path, updated, latest["revision"]))[0] == 422 + finally: + remote(f"from pathlib import Path;Path('{bundle}.gate-backup').rename('{bundle}')") + _, failed = http("GET", config_path) + assert failed["status"] == "failed" and failed["effectiveRevision"] == latest["revision"] + assert pids() == active + await first.close() + for _ in range(20): + if not old_tree.intersection(pids(include_mcp=True)): + break + await asyncio.sleep(0.5) + assert not old_tree.intersection(pids(include_mcp=True)), "disconnect left native or MCP process alive" + assert len(pids()) == 1, "disconnect killed another connection" + text = await second.prompt(other["sessionId"], "Reply only with OK. Do not use tools.") + assert "OK" in text, "existing connection failed after revision update" + # Fresh-session operation after failure uses the retained ready revision. + third = await Client().open() + clients.append(third) + fresh, _ = await third.call("session/new", {"cwd": workspace, "mcpServers": []}) + assert fresh["sessionId"] not in (session["sessionId"], other["sessionId"]) + text = await third.prompt(fresh["sessionId"], "Reply only with READY. Do not use tools.") + assert "READY" in text + summary = {"pass": True, "version": "0.58.0", "model": "gemini-2.5-flash", "prompts": 4, + "authentication": True, "sharedMCP": True, "sessionMCPOverride": True, "credentialIsolation": True, + "nativePermissions": True, "independentConnections": True, "retainedEffectiveRevision": True, + "newSessionsOnly": True, "reconnectDiscoveryLoadHistory": "unsupported; not exercised"} + finally: + for client in clients: + await client.close() + for _ in range(20): + if not pids(include_mcp=True): + break + await asyncio.sleep(0.5) + assert not pids(include_mcp=True), "native or MCP processes remained after disconnect" + print(json.dumps(summary), flush=True) + + +asyncio.run(main()) diff --git a/server/runtime/acp/catalog.json b/server/runtime/acp/catalog.json index eb9d6fe7..951e6d72 100644 --- a/server/runtime/acp/catalog.json +++ b/server/runtime/acp/catalog.json @@ -2,6 +2,14 @@ "acpremote": "/opt/kernel-agent/venv/bin/acpremote", "maxConnections": 8, "harnesses": {}, + "gemini": { + "stateDir": "/home/kernel/.agents/gemini", + "runtimeDir": "/opt/kernel-agent/gemini", + "node": "/usr/local/bin/node", + "credentials": { + "google": "GEMINI_API_KEY" + } + }, "pi": { "stateDir": "/home/kernel/.agents/pi", "runtimeDir": "/opt/kernel-agent/pi", diff --git a/server/runtime/acp/gemini/bun.lock b/server/runtime/acp/gemini/bun.lock new file mode 100644 index 00000000..cb09d8ca --- /dev/null +++ b/server/runtime/acp/gemini/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "kernel-gemini-acp-runtime", + "dependencies": { + "@google/gemini-cli": "0.58.0", + }, + }, + }, + "packages": { + "@github/keytar": ["@github/keytar@7.10.6", "", { "dependencies": { "node-addon-api": "^8.3.0" } }, "sha512-mRW6cUsSG+nj4jp5gp8e91zPySaT73r+2JM6VyMZfrEgksjPmjSMr+tPGNOK3HUHV+GUU9B1LAiiYy/wmAnIxA=="], + + "@google/gemini-cli": ["@google/gemini-cli@0.58.0", "", { "optionalDependencies": { "@github/keytar": "7.10.6", "@lydell/node-pty": "1.1.0", "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", "node-pty": "1.0.0" }, "bin": { "gemini": "bundle/gemini.js" } }, "sha512-++LtUYMcLE8dVxMcuwv6kIp8+h6z+std/7iVE+vSunkrwNDaWMFkWw/psv2RSySWjr2A1SsEEIGCK0xULWY2sA=="], + + "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], + + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], + + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA=="], + + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg=="], + + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA=="], + + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w=="], + + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="], + + "nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="], + + "node-addon-api": ["node-addon-api@8.9.2", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="], + + "node-pty": ["node-pty@1.0.0", "", { "dependencies": { "nan": "^2.17.0" } }, "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA=="], + } +} diff --git a/server/runtime/acp/gemini/launch.mjs b/server/runtime/acp/gemini/launch.mjs new file mode 100644 index 00000000..d74ea2df --- /dev/null +++ b/server/runtime/acp/gemini/launch.mjs @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const config = JSON.parse(readFileSync(process.argv[2], "utf8")); +const bindings = JSON.parse(process.env.KERNEL_GEMINI_BINDINGS); +const env = Object.fromEntries( + ["PATH", "HOME", "USER", "LANG", "TMPDIR", "TERM", "SSL_CERT_FILE", "SSL_CERT_DIR", + "GEMINI_CLI_HOME", "GEMINI_CLI_SYSTEM_SETTINGS_PATH", + "GEMINI_CLI_SYSTEM_DEFAULTS_PATH", "GEMINI_CLI_TRUST_WORKSPACE"] + .filter((name) => process.env[name] !== undefined) + .map((name) => [name, process.env[name]]), +); +env.GEMINI_CLI_NO_RELAUNCH = "true"; +env.GEMINI_API_KEY = process.env[process.env.KERNEL_GEMINI_PROVIDER_SOURCE]; +for (const [alias, source] of Object.entries(bindings)) env[alias] = process.env[source]; +// No protocol interception: the CLI owns ACP and its native state. +const child = spawn(process.execPath, [ + fileURLToPath(new URL("./node_modules/@google/gemini-cli/bundle/gemini.js", import.meta.url)), + "--experimental-acp", "--model", config.launch.model, "--extensions", "none", +], { env, stdio: "inherit" }); +for (const signal of ["SIGTERM", "SIGINT"]) + process.on(signal, () => child.kill(signal)); +child.on("error", () => process.exit(1)); +child.on("exit", (code) => process.exit(code ?? 1)); diff --git a/server/runtime/acp/gemini/package.json b/server/runtime/acp/gemini/package.json new file mode 100644 index 00000000..75185e3f --- /dev/null +++ b/server/runtime/acp/gemini/package.json @@ -0,0 +1,8 @@ +{ + "name": "kernel-gemini-acp-runtime", + "private": true, + "type": "module", + "dependencies": { + "@google/gemini-cli": "0.58.0" + } +}