From 1628402afc978da6c2658fc0d4ab06fe085f9101 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:24:19 +0000 Subject: [PATCH 1/5] Add pinned Codex ACP reference harness --- .github/workflows/server-test.yaml | 7 + images/chromium-headful/Dockerfile | 2 + images/chromium-headless/image/Dockerfile | 2 + server/lib/agentproxy/CODEX.md | 162 ++++++++++ server/lib/agentproxy/README.md | 6 +- server/lib/agentproxy/codex.go | 165 ++++++++++ server/lib/agentproxy/codex_http.go | 62 ++++ server/lib/agentproxy/codex_test.go | 203 ++++++++++++ server/lib/agentproxy/config.go | 8 +- server/lib/agentproxy/handler.go | 22 +- server/lib/agentproxy/testdata/codex_gate.py | 309 +++++++++++++++++++ server/runtime/acp/catalog.json | 8 + server/runtime/acp/codex/bun.lock | 70 +++++ server/runtime/acp/codex/launch.mjs | 42 +++ server/runtime/acp/codex/mcp-command.mjs | 9 + server/runtime/acp/codex/package.json | 13 + server/runtime/acp/codex/prepare.mjs | 31 ++ server/runtime/acp/codex/process.mjs | 17 + server/runtime/acp/codex/settings.mjs | 39 +++ server/runtime/acp/codex/settings.test.mjs | 82 +++++ 20 files changed, 1254 insertions(+), 5 deletions(-) create mode 100644 server/lib/agentproxy/CODEX.md create mode 100644 server/lib/agentproxy/codex.go create mode 100644 server/lib/agentproxy/codex_http.go create mode 100644 server/lib/agentproxy/codex_test.go create mode 100644 server/lib/agentproxy/testdata/codex_gate.py create mode 100644 server/runtime/acp/codex/bun.lock create mode 100644 server/runtime/acp/codex/launch.mjs create mode 100644 server/runtime/acp/codex/mcp-command.mjs create mode 100644 server/runtime/acp/codex/package.json create mode 100644 server/runtime/acp/codex/prepare.mjs create mode 100644 server/runtime/acp/codex/process.mjs create mode 100644 server/runtime/acp/codex/settings.mjs create mode 100644 server/runtime/acp/codex/settings.test.mjs diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 6517315c..d96ffccf 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -64,6 +64,13 @@ jobs: node patch-adapter.mjs echo "AGENT_PI_TEST_RUNTIME=$PWD" >> "$GITHUB_ENV" + - name: Install pinned Codex reference runtime + working-directory: server/runtime/acp/codex + run: | + bun install --frozen-lockfile --ignore-scripts + node --test *.test.mjs + echo "AGENT_CODEX_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..cb32adc1 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/codex /opt/kernel-agent/codex +RUN cd /opt/kernel-agent/codex && 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..07438866 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/codex /opt/kernel-agent/codex +RUN cd /opt/kernel-agent/codex && 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/CODEX.md b/server/lib/agentproxy/CODEX.md new file mode 100644 index 00000000..9d219e81 --- /dev/null +++ b/server/lib/agentproxy/CODEX.md @@ -0,0 +1,162 @@ +# Packaged Codex ACP reference + +Codex is an independent declarative harness alongside Pi. It uses the existing +configuration/revision manager and connection-owned WebSocket bridge unchanged. +ACP owns initialization, authentication, sessions, history, prompts, cancellation, +permissions and model/mode controls. There is no Kernel conversation protocol, +session registry, saved proxy output or automatic prompt retry. + +## Configure + +Inject `OPENAI_API_KEY` into the image environment. GET +`/agent/v1/harnesses/codex/config` and PUT with its ETag in `If-Match`: + +```json +{ + "launch": { + "provider": "openai", + "model": "gpt-5.4-mini", + "thinking": "low", + "mode": "read-only", + "credential": "openai" + }, + "shared": { + "instructions": "Keep replies brief.", + "webSearch": "disabled", + "mcpServers": [] + } +} +``` + +Connect to `/agent/v1/acp?harness=codex`. Call ACP `initialize`, then +`authenticate` with `{"methodId":"api-key"}`. No key value is needed in ACP; +the adapter reads the selected environment binding. `NO_BROWSER=1` disables +browser-based ChatGPT login. The managed profile uses native ephemeral auth +storage, so API-key authentication does not persist a key in `auth.json`. +Reauthenticate on each fresh connection. The catalog's `codex.credentials` can +add operator-defined binding names; requests contain names, never secret values. +Only referenced bindings reach the launch wrapper. It passes the provider key +under `CODEX_API_KEY` and removes binding source names and ambient adapter +configuration/logging variables from the child environment. + +### Supported configuration + +- `provider`: `openai` only in this preparer. Custom gateways, ChatGPT OAuth, + other providers and Bedrock are not managed configuration options. +- `model`: native Codex model ID. `thinking`: `minimal`, `low`, `medium`, `high`, + `xhigh`; the selected model may accept only a subset. ACP config/model controls + can change a session's selection. +- `mode`: `read-only`, `agent` (workspace-write), `agent-full-access`. These are + native adapter modes, not an additional Kernel sandbox. ACP owns permissions + and subsequent mode changes. +- `shared.instructions`: native `developer_instructions` (up to 64 KiB). + `shared.webSearch`: native `web_search`, `disabled`, `cached` or `live`. +- `shared.mcpServers`: native stdio and streamable HTTP (up to 32 servers). + Pi extensions, package installation, arbitrary native config passthrough, + managed skills/plugins and SSE MCP are explicitly unsupported. Unknown fields + are rejected, not silently ignored. + +## Native MCP + +Use the [shared MCP definition](README.md#shared-mcp-configuration) with +`command`/`args`/`envBindings`, or `url`/`transport: "http"`/`headerBindings`. +Server names must use letters, digits, underscores or hyphens (1–64 characters). +Commands are absolute remote executable paths; preparation checks executability, +not the MCP handshake. URLs cannot contain credentials, queries or fragments. + +Native `config.toml` contains no credential values. HTTP headers use native +`env_http_headers` with per-definition hashed environment aliases. Stdio uses +native `env_vars` and a small command wrapper to remap those aliases into the +requested variable names. Different servers can bind the same destination name +to different credentials. MCP children receive platform-default environment plus +only their own bindings, not the provider environment. A changed server definition +gets different aliases so an old connection cannot send an old token to a new +URL. Reconnect after credential or bound-server changes; old processes may lack +the new aliases. HTTP services with required auth should reject missing headers. + +ACP `session/new` and `session/load` accept native session MCP definitions. +**The pinned adapter preserves configured shared servers on name conflicts**; +client definitions with the same name are filtered out. Unlike Pi, these do not +override shared defaults. Use distinct names for session additions. Session MCP +configuration is not written into the managed shared file; supply it again on +load. No adapter patch or protocol interception is used. + +Native project `.codex/config.toml`, `AGENTS.md`, skills and other native resources +can participate according to Codex's own rules. The adapter trusts the remote +session `cwd`; choose it deliberately. This is not a sandbox against code with +browser filesystem access. Interactive MCP OAuth and client gateway auth are +upstream ACP capabilities, not managed/binding-based features tested here. + +## Revisions and lifetime + +The common GET/PUT semantics apply: desired/effective revisions, safe failed +preparation retention, required optimistic `If-Match`, 409 for stale or concurrent +writes, private revision files and last-ready recovery after restart. Preparation +is capped at 30 seconds, validates exact installed runtime versions and native +TOML using `codex mcp list --json`, and does not authenticate, start MCP servers or +call a provider. A ready revision is not proof of remote provider/MCP availability. + +`/home/kernel/.agents/codex/native` is the stable `CODEX_HOME`; native sessions, +SQLite state and history remain outside replaceable revisions. Its `config.toml` +symlink follows the atomic `current` revision. It is established at launch, not +during preparation. An unexpected existing native config file is not overwritten; +launch fails instead. The operator may change `stateDir`, `runtimeDir`, `node` +and credential bindings in the catalog. + +Each connection owns an independent adapter + Codex app-server process tree. +Disconnect cleans up processes, not saved sessions. Updates do not kill existing +connections. Native shared settings can be observed dynamically; full revision +isolation is not promised. Credentials and initial mode are captured at launch. +Successful revisions are retained for old processes; there is no automatic GC. +Direct native/file edits are not reconciled into the API's desired state. + +Reconnect with fresh `initialize` → `authenticate` → paginated `session/list` +(follow `nextCursor` even when a filtered page is empty) → `session/load` with the +exact original ID, remote `cwd` and session MCP definitions. Load replays native +history and permits a fresh prompt using that history. `session/resume` is +optional and is rejected by acpremote 1.7.0's local router. No interrupted-turn +continuation, exactly-once side effects or lossless event replay is promised. + +## Pins and tests + +Both images install **@agentclientprotocol/codex-acp 1.10.0** and +**@openai/codex 0.153.3** (including its matching native platform dependency). +The override prevents the adapter's upstream `^0.153.3` range drifting. +**smol-toml 1.4.2** serializes native config; `runtime/acp/codex/bun.lock` pins the +complete npm resolution. Installation uses Bun **1.4.0**, frozen lockfile and no +lifecycle scripts. Bridge/client versions are unchanged from the Pi base: +acpremote **1.7.0**, Python ACP SDK **0.11.0**, websockets **15.0.1**, packaged +Python **3.12.11**. Native source reviewed at adapter commit +`061f9a4a2e463a220d7a3ab2ae5e9732837085ef` and Codex commit +`b1a547b1f73ce86205d9222ac19cff334b3b7a2e`. + +```sh +cd server/runtime/acp/codex +bun install --frozen-lockfile --ignore-scripts +node --test *.test.mjs +cd ../../.. +AGENT_CODEX_TEST_RUNTIME="$PWD/runtime/acp/codex" \ +AGENT_PROXY_TEST_ACPREMOTE=/path/to/acp-venv/bin/acpremote \ + go test -race ./lib/agentproxy ./lib/wsproxy +``` + +Unit tests cover configuration validation, HTTP concurrency/failure/recovery, +native preparation, private files, no persisted credentials, environment +isolation, native settings and safe credential-alias changes. The native test +skips explicitly without `AGENT_CODEX_TEST_RUNTIME`. CI installs both runtimes. + +Opt-in paid gate, against a **fresh disposable image** with `OPENAI_API_KEY`: + +```sh +AGENT_API_URL=http://127.0.0.1:10001 \ + /path/to/acp-venv/bin/python lib/agentproxy/testdata/codex_gate.py +``` + +This refuses to overwrite an existing configuration and uses small bounded +`gpt-5.4-mini` turns. It tests independent connections, shared/session stdio MCP, +updates and failed preparation while connected, disconnect cleanup and fresh +list/load/history/model recall. Test transcripts are local private test evidence, +not proxy output persistence. HTTP MCP, media, native extensions, cancellation +mid-tool and provider/model combinations beyond the stated gates need separate +validation. Packaged headless testing does not validate the platform gateway, +TLS/authentication boundary, arm64 or headful desktop behavior. diff --git a/server/lib/agentproxy/README.md b/server/lib/agentproxy/README.md index 34951f58..7393085c 100644 --- a/server/lib/agentproxy/README.md +++ b/server/lib/agentproxy/README.md @@ -1,6 +1,6 @@ # ACP agents -The browser images bundle a pinned Pi reference implementation. Kernel manages +The browser images bundle pinned Pi and [Codex](CODEX.md) reference implementations. 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 +9,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":["codex","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,7 +200,7 @@ 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 +provisioned agents. Pi and Codex have independent packaged declarative preparers. 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 diff --git a/server/lib/agentproxy/codex.go b/server/lib/agentproxy/codex.go new file mode 100644 index 00000000..0bcf1a33 --- /dev/null +++ b/server/lib/agentproxy/codex.go @@ -0,0 +1,165 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" +) + +type CodexOptions struct { + StateDir string `json:"stateDir"` + RuntimeDir string `json:"runtimeDir"` + Node string `json:"node"` + Credentials map[string]string `json:"credentials"` +} + +type CodexConfiguration struct { + Launch CodexLaunch `json:"launch"` + Shared CodexShared `json:"shared"` +} + +type CodexLaunch struct { + Provider string `json:"provider"` + Model string `json:"model"` + Thinking string `json:"thinking"` + Mode string `json:"mode"` + Credential string `json:"credential"` +} + +type CodexShared struct { + Instructions string `json:"instructions,omitempty"` + WebSearch string `json:"webSearch"` + MCPServers []ManagedMCPServer `json:"mcpServers"` +} + +var codexMCPName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +var codexEnvName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +func (p CodexOptions) validate() error { + if !filepath.IsAbs(p.StateDir) || !filepath.IsAbs(p.RuntimeDir) || !filepath.IsAbs(p.Node) { + return errors.New("codex paths must be absolute") + } + for name, source := range p.Credentials { + if name == "" || !codexEnvName.MatchString(source) || !validEnvName(source) { + return errors.New("invalid credential binding") + } + } + return nil +} + +func (p CodexOptions) validateDesired(c CodexConfiguration) error { + if c.Launch.Provider != "openai" || strings.TrimSpace(c.Launch.Model) == "" || len(c.Launch.Model) > 512 || strings.HasPrefix(c.Launch.Model, "-") || strings.ContainsAny(c.Launch.Model, "\x00\r\n") { + return errors.New("codex requires the openai provider and a model") + } + switch c.Launch.Thinking { + case "minimal", "low", "medium", "high", "xhigh": + default: + return errors.New("invalid codex thinking level") + } + switch c.Launch.Mode { + case "read-only", "agent", "agent-full-access": + default: + return errors.New("invalid codex mode") + } + switch c.Shared.WebSearch { + case "disabled", "cached", "live": + default: + return errors.New("invalid codex webSearch setting") + } + if len(c.Shared.Instructions) > 64<<10 || strings.ContainsRune(c.Shared.Instructions, '\x00') { + return errors.New("invalid codex instructions") + } + bindings, err := validateMCPServers(c.Shared.MCPServers) + if err != nil { + return err + } + for _, s := range c.Shared.MCPServers { + if !codexMCPName.MatchString(s.Name) || len(s.Name) > 64 { + return errors.New("codex MCP names must use letters, digits, underscores or hyphens (at most 64)") + } + if s.Transport == "sse" { + return errors.New("codex does not support SSE MCP") + } + if strings.ContainsRune(s.Command, '\x00') { + return errors.New("invalid MCP command") + } + for name := range s.EnvBindings { + if !codexEnvName.MatchString(name) { + return errors.New("invalid MCP environment name") + } + } + for _, header := range s.HeaderBindings { + value := os.Getenv(p.Credentials[header.Credential]) + if strings.ContainsAny(value, "\r\n\x00") { + return errors.New("invalid MCP header credential") + } + } + } + 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 CodexOptions) Prepare(ctx context.Context, dir string, desired json.RawMessage) (Harness, error) { + var c CodexConfiguration + 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 + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, p.Node, filepath.Join(p.RuntimeDir, "prepare.mjs"), dir) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } + command.WaitDelay = time.Second + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir} + err := command.Run() + if command.Process != nil { + _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + } + if err != nil { + return Harness{}, errors.New("codex runtime or configuration validation failed") + } + refs := map[string]string{c.Launch.Credential: p.Credentials[c.Launch.Credential]} + for _, s := range c.Shared.MCPServers { + for _, b := range s.EnvBindings { + refs[b] = p.Credentials[b] + } + for _, h := range s.HeaderBindings { + refs[h.Credential] = p.Credentials[h.Credential] + } + } + bindings, _ := json.Marshal(refs) + inherited := make([]string, 0, len(refs)) + for _, source := range refs { + inherited = append(inherited, source) + } + 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"), + "CODEX_HOME": filepath.Join(p.StateDir, "native"), + "KERNEL_CODEX_STATE": p.StateDir, + "KERNEL_CODEX_BINDINGS": string(bindings), + }, + InheritEnv: inherited, + }, nil +} diff --git a/server/lib/agentproxy/codex_http.go b/server/lib/agentproxy/codex_http.go new file mode 100644 index 00000000..05d026bf --- /dev/null +++ b/server/lib/agentproxy/codex_http.go @@ -0,0 +1,62 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +func (h *Handler) codexConfiguration(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 CodexConfiguration + if err := decoder.Decode(&desired); err != nil { + http.Error(w, "invalid codex 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 err := h.config.Codex.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.codex.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.codex.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/codex_test.go b/server/lib/agentproxy/codex_test.go new file mode 100644 index 00000000..aa01f00e --- /dev/null +++ b/server/lib/agentproxy/codex_test.go @@ -0,0 +1,203 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func codexDesired() CodexConfiguration { + return CodexConfiguration{ + Launch: CodexLaunch{Provider: "openai", Model: "gpt-5.4-mini", Thinking: "low", Mode: "read-only", Credential: "provider"}, + Shared: CodexShared{WebSearch: "disabled", MCPServers: make([]ManagedMCPServer, 0)}, + } +} + +func TestCodexValidation(t *testing.T) { + t.Setenv("CODEX_TEST_KEY", "private-key") + p := CodexOptions{Credentials: map[string]string{"provider": "CODEX_TEST_KEY"}} + if err := p.validateDesired(codexDesired()); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*CodexConfiguration){ + "provider": func(c *CodexConfiguration) { c.Launch.Provider = "bedrock" }, + "model": func(c *CodexConfiguration) { c.Launch.Model = "\nsecret" }, + "thinking": func(c *CodexConfiguration) { c.Launch.Thinking = "off" }, + "mode": func(c *CodexConfiguration) { c.Launch.Mode = "bypass" }, + "credential": func(c *CodexConfiguration) { c.Launch.Credential = "unknown" }, + "webSearch": func(c *CodexConfiguration) { c.Shared.WebSearch = "yes" }, + "instructions": func(c *CodexConfiguration) { c.Shared.Instructions = "\x00" }, + "sse": func(c *CodexConfiguration) { + c.Shared.MCPServers = []ManagedMCPServer{{Name: "docs", URL: "https://example.com", Transport: "sse"}} + }, + "name": func(c *CodexConfiguration) { + c.Shared.MCPServers = []ManagedMCPServer{{Name: "docs.dot", Command: "/bin/true"}} + }, + "env": func(c *CodexConfiguration) { + c.Shared.MCPServers = []ManagedMCPServer{{Name: "docs", Command: "/bin/true", EnvBindings: map[string]string{"bad-name": "provider"}}} + }, + } { + t.Run(name, func(t *testing.T) { + c := codexDesired() + change(&c) + if err := p.validateDesired(c); err == nil { + t.Fatal("accepted invalid configuration") + } + }) + } +} + +func TestCodexHTTPConcurrencyAndFailure(t *testing.T) { + t.Setenv("CODEX_TEST_KEY", "private-key") + p := &CodexOptions{StateDir: t.TempDir(), RuntimeDir: "/runtime", Node: "/bin/node", Credentials: map[string]string{"provider": "CODEX_TEST_KEY"}} + h, err := New(context.Background(), Config{ACPRemote: "/bin/acpremote", MaxConnections: 1, Codex: p, Harnesses: map[string]Harness{"codex": {Command: "/bin/true", Cwd: "/tmp"}}}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + t.Fatal(err) + } + started, release := make(chan struct{}), make(chan struct{}) + h.codex.preparer = prepareFunc(func(ctx context.Context, dir string, _ json.RawMessage) (Harness, error) { + close(started) + select { + case <-release: + case <-ctx.Done(): + return Harness{}, ctx.Err() + } + return Harness{Command: "/bin/true", Cwd: dir}, nil + }) + path := "/agent/v1/harnesses/codex/config" + request := func(method, url, body, etag string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, url, strings.NewReader(body)) + r.Header.Set("If-Match", etag) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + if w := request("GET", "/agent/v1/harnesses", "", ""); w.Body.String() != "{\"configured\":[\"codex\"]}\n" { + t.Fatal(w.Body.String()) + } + if w := request("GET", "/agent/v1/acp?harness=codex", "", ""); w.Code != 409 { + t.Fatal(w.Code) + } + initial := request("GET", path, "", "") + if initial.Header().Get("ETag") != `"0"` { + t.Fatal(initial.Header()) + } + data, _ := json.Marshal(codexDesired()) + body := string(data) + if w := request("PUT", path, body, ""); w.Code != 428 { + t.Fatal(w.Code) + } + if w := request("PUT", path, strings.Replace(body, `"webSearch"`, `"extensions"`, 1), `"0"`); w.Code != 400 { + t.Fatal(w.Code) + } + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- request("PUT", path, body, `"0"`) }() + <-started + if w := request("GET", path, "", ""); !strings.Contains(w.Body.String(), `"status":"preparing"`) { + t.Fatal(w.Body.String()) + } + if w := request("PUT", path, body, `"0"`); w.Code != 409 { + t.Fatal(w.Code) + } + close(release) + ready := <-done + if ready.Code != 200 { + t.Fatal(ready.Body.String()) + } + if strings.Contains(ready.Body.String(), "private-key") || strings.Contains(ready.Body.String(), "CODEX_TEST_KEY") || !strings.Contains(ready.Body.String(), `"mcpServers":[]`) { + t.Fatal(ready.Body.String()) + } + if w := request("PUT", path, body, `"0"`); w.Code != 409 { + t.Fatal(w.Code) + } + h.codex.preparer = prepareFunc(func(context.Context, string, json.RawMessage) (Harness, error) { return Harness{}, os.ErrPermission }) + if w := request("PUT", path, body, ready.Header().Get("ETag")); w.Code != 422 { + t.Fatal(w.Code) + } + failed := h.codex.snapshot() + if failed.Status != "failed" || failed.EffectiveRevision == failed.Revision || failed.EffectiveRevision == "" { + t.Fatal(failed) + } + restored, err := newConfigurationManager(p.StateDir, *p) + if err != nil || restored.snapshot().EffectiveRevision != failed.EffectiveRevision { + t.Fatal(err) + } + if _, ok := restored.preparedLaunch(); !ok { + t.Fatal("lost launch") + } + if w := request(http.MethodDelete, path, "", ""); w.Code != 405 { + t.Fatal(w.Code) + } +} + +func TestCodexNativePreparation(t *testing.T) { + runtime := os.Getenv("AGENT_CODEX_TEST_RUNTIME") + if runtime == "" { + t.Skip("set AGENT_CODEX_TEST_RUNTIME to the installed pinned Codex runtime") + } + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + t.Setenv("CODEX_TEST_KEY", "private-test-credential") + p := CodexOptions{StateDir: t.TempDir(), RuntimeDir: runtime, Node: node, Credentials: map[string]string{"provider": "CODEX_TEST_KEY"}} + m, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + c := codexDesired() + c.Shared.MCPServers = []ManagedMCPServer{{Name: "docs", Command: "/bin/true", EnvBindings: map[string]string{"TOKEN": "provider"}}, {Name: "remote", URL: "https://example.com/mcp", Transport: "http", HeaderBindings: map[string]CredentialHeader{"Authorization": {Credential: "provider", Prefix: "Bearer "}}}} + data, _ := json.Marshal(c) + if err = m.apply(context.Background(), "0", data); err != nil { + t.Fatal(err) + } + ready := m.snapshot() + if err = filepath.WalkDir(p.StateDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(content), "private-test-credential") { + t.Errorf("credential persisted in %s", path) + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode().Perm()&0077 != 0 { + t.Errorf("non-private file %s", path) + } + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(p.StateDir, "native")); !os.IsNotExist(err) { + t.Fatal("preparation touched native state") + } + c.Shared.MCPServers[0].Command = "/nonexistent/codex-mcp-test" + data, _ = json.Marshal(c) + if err = m.apply(context.Background(), ready.Revision, data); err == nil { + t.Fatal("missing MCP command activated") + } + failed := m.snapshot() + if failed.Status != "failed" || failed.EffectiveRevision != ready.Revision { + t.Fatal(failed) + } + if _, err := os.Stat(filepath.Join(p.StateDir, "revisions", failed.Revision)); !os.IsNotExist(err) { + t.Fatal("failed directory retained") + } +} diff --git a/server/lib/agentproxy/config.go b/server/lib/agentproxy/config.go index 40d3ebf6..2db830f5 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"` + Codex *CodexOptions `json:"codex,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.Codex != nil { + if err := c.Codex.validate(); err != nil { + return err + } + } + if len(c.Harnesses) == 0 && c.Pi == nil && c.Codex == nil { return errors.New("at least one harness is required") } for name, harness := range c.Harnesses { diff --git a/server/lib/agentproxy/handler.go b/server/lib/agentproxy/handler.go index d294cda2..859e2d32 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 + codex *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.Codex != nil { + var err error + h.codex, err = newConfigurationManager(config.Codex.StateDir, *config.Codex) + 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.codex != nil { + names = append(names, "codex") + } for name := range h.config.Harnesses { - if name == "pi" && h.pi != nil { + if (name == "pi" && h.pi != nil) || (name == "codex" && h.codex != 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/codex/config" && h.codex != nil: + h.codexConfiguration(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 == "codex" && h.codex != nil { + harness, ok = h.codex.preparedLaunch() + if !ok { + http.Error(w, "codex 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/codex_gate.py b/server/lib/agentproxy/testdata/codex_gate.py new file mode 100644 index 00000000..cab21415 --- /dev/null +++ b/server/lib/agentproxy/testdata/codex_gate.py @@ -0,0 +1,309 @@ +"""Opt-in real-provider gate. Use a fresh disposable image with OPENAI_API_KEY. +Requires the pinned ACP requirements in the test runner's Python environment. +""" + +import asyncio, base64, json, os, pathlib, tempfile, urllib.request, urllib.error, uuid +import websockets + +os.umask(0o077) +base = os.environ["AGENT_API_URL"].rstrip("/") +assert base.startswith(("http://", "https://")), "AGENT_API_URL must be HTTP(S)" +root = pathlib.Path(tempfile.mkdtemp(prefix="codex-gate-evidence-")) +workspace = "/tmp/codex-gate-" + uuid.uuid4().hex + + +def http(method, path, data=None, revision=None): + headers = {"Content-Type": "application/json"} + if revision: + 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: + response = urllib.request.urlopen(request, timeout=200) + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + + +def pids(): + code = """import pathlib,json +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 any(a.startswith(b'/opt/kernel-agent/codex/') for a in args):pids.append(path.name) +print(json.dumps(pids)) +""" + status, result = http( + "POST", + "/process/exec", + {"command": "/opt/kernel-agent/venv/bin/python", "args": ["-c", code]}, + ) + assert status == 200 and result["exit_code"] == 0, result + return set(json.loads(base64.b64decode(result["stdout_b64"]))) + + +config_path = "/agent/v1/harnesses/codex/config" +status, initial = http("GET", config_path) +assert status == 200 +assert initial["status"] == "unconfigured", ( + "Use a fresh disposable browser; refusing to replace existing configuration." +) +source = base64.b64encode( + pathlib.Path(__file__).with_name("mcp_checkpoint.py").read_bytes() +).decode() +code = ( + "import pathlib,base64; p=pathlib.Path('" + + workspace + + "'); p.mkdir(); (p/'mcp.py').write_bytes(base64.b64decode('" + + source + + "'))" +) +assert ( + http( + "POST", + "/process/exec", + {"command": "/opt/kernel-agent/venv/bin/python", "args": ["-c", code]}, + )[0] + == 200 +) +shared = { + "name": "checkpoint", + "command": "/opt/kernel-agent/venv/bin/python", + "args": [workspace + "/mcp.py", "shared", workspace + "/calls"], +} +desired = { + "launch": { + "provider": "openai", + "model": "gpt-5.4-mini", + "thinking": "low", + "credential": "openai", + "mode": "read-only", + }, + "shared": {"webSearch": "disabled", "instructions": "Keep replies brief.", "mcpServers": [shared]}, +} +status, ready = http("PUT", config_path, desired, initial["revision"]) +assert status == 200, (status, ready) +print("configuration ready", ready["revision"], flush=True) + + +class Client: + def __init__(self): + self.seq = 0 + self.events = [] + + async def open(self): + self.ws = await websockets.connect( + "ws" + base[4:] + "/agent/v1/acp?harness=codex", max_size=1 << 20 + ) + try: + await self.call( + "initialize", {"protocolVersion": 1, "clientCapabilities": {}} + ) + await self.call("authenticate", {"methodId": "api-key"}) + except BaseException: + await self.ws.close() + raise + return self + + async def close(self): + await self.ws.close() + await asyncio.sleep(2) + + async def call(self, method, params): + async with asyncio.timeout(90): + return await self._call(method, params) + + 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 = [] + while True: + message = json.loads(await asyncio.wait_for(self.ws.recv(), 150)) + self.events.append(message) + if message.get("method") == "session/request_permission": + 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, message + 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(): + clients = [] + summary = {} + try: + first = await Client().open() + clients.append(first) + session, _ = await first.call( + "session/new", {"cwd": workspace, "mcpServers": []} + ) + sid = session["sessionId"] + old = pids() + assert old + marker = "cobalt-" + uuid.uuid4().hex[:8] + text = await first.prompt( + sid, + "Remember this checkpoint: " + + marker + + ". Reply only with the checkpoint. Do not use tools.", + ) + assert marker in text, text + second = await Client().open() + clients.append(second) + custom = dict( + shared, + name="session_checkpoint", + args=[workspace + "/mcp.py", "session", workspace + "/calls"], + env=[], + ) + other, _ = await second.call( + "session/new", {"cwd": workspace, "mcpServers": [custom]} + ) + assert other["sessionId"] != sid + assert len(pids()) > len(old) + text = await second.prompt( + other["sessionId"], + "Call the session_checkpoint MCP checkpoint tool once and return its output. Do not use other tools.", + ) + assert "session" in text, text + text = await first.prompt( + sid, + "Call the checkpoint MCP tool once and return its output. Do not use other tools.", + ) + assert "shared" in text, text + status, calls = http("POST", "/process/exec", { + "command": "/bin/cat", "args": [workspace + "/calls"] + }) + assert status == 200 and calls["exit_code"] == 0 + assert set(base64.b64decode(calls["stdout_b64"]).decode().splitlines()) == {"shared", "session"} + updated = json.loads(json.dumps(desired)) + updated["shared"]["mcpServers"][0]["args"][1] = "updated-shared" + active = pids() + code, latest = await asyncio.to_thread( + http, "PUT", config_path, updated, ready["revision"] + ) + assert code == 200, (code, latest) + assert pids() == active, "configuration update restarted active connections" + code, _ = http("PUT", config_path, updated, ready["revision"]) + assert code == 409, "stale If-Match accepted" + broken = json.loads(json.dumps(updated)) + broken["shared"]["mcpServers"][0]["command"] = "/nonexistent/codex-gate-mcp" + code, error = await asyncio.to_thread( + http, "PUT", config_path, broken, latest["revision"] + ) + assert code == 422, (code, error) + code, failed = http("GET", config_path) + assert ( + failed["status"] == "failed" + and failed["effectiveRevision"] == latest["revision"] + ) + # Native forks persist real history without additional provider turns. + # More than the native 25-item page size forces cursor traversal. + for _ in range(26): + forked, _ = await first.call("session/fork", { + "sessionId": sid, "cwd": workspace, "mcpServers": [] + }) + await first.call("session/close", {"sessionId": forked["sessionId"]}) + await first.close() + assert not old.intersection(pids()), (old, pids()) + text = await second.prompt( + other["sessionId"], "Reply only with OK. Do not use tools." + ) + assert "OK" in text + reconnected = await Client().open() + clients.append(reconnected) + cursor, sessions, pages = None, [], 0 + while True: + params = {"cwd": workspace} + if cursor: + params["cursor"] = cursor + listed, _ = await reconnected.call("session/list", params) + pages += 1 + sessions.extend(listed["sessions"]) + cursor = listed.get("nextCursor") + if not cursor: + break + assert pages < 100, "pagination did not terminate" + assert pages >= 2, "native history did not exercise pagination" + assert any(s["sessionId"] == sid for s in sessions) + _, history = await reconnected.call( + "session/load", {"sessionId": sid, "cwd": workspace, "mcpServers": []} + ) + assert marker in json.dumps(history) + text = await reconnected.prompt( + sid, + "What was the checkpoint word in our first exchange? Reply only with it. Do not use tools.", + ) + assert marker in text, text + text = await reconnected.prompt( + sid, + "Call the checkpoint MCP tool once and return its output. Do not use other tools.", + ) + assert "updated-shared" in text, text + summary = { + "configurationUpdateKeepsConnections": True, + "updatedSharedMCP": True, + "pass": True, + "independentConnections": True, + "failedPreparationRetainsReady": True, + "disconnectKillsAdapter": True, + "freshListLoadHistoryAndRecall": True, + "sharedMCP": True, + "sessionMCPAddition": True, + "listPages": pages, + "sessionId": sid, + } + finally: + for c in clients: + await c.close() + assert not pids(), pids() + (root / "summary.json").write_text(json.dumps(summary, indent=2)) + (root / "events.json").write_text( + json.dumps([c.events for c in clients], indent=2) + ) + print(json.dumps(summary), flush=True) + + +asyncio.run(asyncio.wait_for(main(), 600)) +print("evidence:", root) diff --git a/server/runtime/acp/catalog.json b/server/runtime/acp/catalog.json index eb9d6fe7..2fccdaa1 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": {}, + "codex": { + "stateDir": "/home/kernel/.agents/codex", + "runtimeDir": "/opt/kernel-agent/codex", + "node": "/usr/local/bin/node", + "credentials": { + "openai": "OPENAI_API_KEY" + } + }, "pi": { "stateDir": "/home/kernel/.agents/pi", "runtimeDir": "/opt/kernel-agent/pi", diff --git a/server/runtime/acp/codex/bun.lock b/server/runtime/acp/codex/bun.lock new file mode 100644 index 00000000..f64593ac --- /dev/null +++ b/server/runtime/acp/codex/bun.lock @@ -0,0 +1,70 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "kernel-codex-acp-runtime", + "dependencies": { + "@agentclientprotocol/codex-acp": "1.10.0", + "@openai/codex": "0.153.3", + "smol-toml": "1.4.2", + }, + }, + }, + "overrides": { + "@openai/codex": "0.153.3", + }, + "packages": { + "@agentclientprotocol/codex-acp": ["@agentclientprotocol/codex-acp@1.10.0", "", { "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "@openai/codex": "^0.153.3", "diff": "^9.0.0", "open": "^11.0.1", "vscode-jsonrpc": "^9.0.1", "zod": "^4.0.0" }, "bin": { "codex-acp": "dist/index.js" } }, "sha512-b4dDCPkH/GgHRb3JelXz4QdNirdoTjO3yYM1ImUJMxVXxgZLZMguEXNhZOH2G755UKzf6ZypiiC7UHe3fKgp2Q=="], + + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.4.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg=="], + + "@openai/codex": ["@openai/codex@0.153.3", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.153.3-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.153.3-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.153.3-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.153.3-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.153.3-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.153.3-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-SwQns+YIXvaXV4a6RUd9twgTPJkkfZpuTNEkTtIkwBnfw6fpT61+d6gU1WHZxK6vWFNqJCBoJEP69FIAsoPduA=="], + + "@openai/codex-darwin-arm64": ["@openai/codex@0.153.3-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cIJh2xhww3ZBXmgBt6e9gQjrdL2c9xiiq7yimDHtTGdg3wiUOADD/OsmIu/RJOitwM8OgImdHFjR59doBBdo8A=="], + + "@openai/codex-darwin-x64": ["@openai/codex@0.153.3-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-82Jxir4LygzF32z4y9KOMhK8rVlA4T6c10I9/MK+FhkEmTnDYcL4+VyttHdnMrX0I/Bdqj2M71gFellIaJHiDQ=="], + + "@openai/codex-linux-arm64": ["@openai/codex@0.153.3-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-uayZKgz5lk7Pz3JPjL98FGQGFRcrax3sGEQ9CtduyZscbIcnn3iYszRvGcubqVwiHi3B9oAVqy8V2NcWGwImLg=="], + + "@openai/codex-linux-x64": ["@openai/codex@0.153.3-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-CyLvuM9Ij7sQuNtnHrzHtF+DtNISaauZZakyhy+MoFS1XxKTGv3FN4/qh6c94YZtLStZ7aUmfYQz59V3PV4zng=="], + + "@openai/codex-win32-arm64": ["@openai/codex@0.153.3-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-Y5so5McyGWjfyj6q1qzgwT5UOMGo0jGPXECy88p2XWAwavtnHicoh6f4xi7VMgxEc/dxUijMPMVRJ5kqrBseXQ=="], + + "@openai/codex-win32-x64": ["@openai/codex@0.153.3-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-+3BNGznK6xRYrBi8Ivsz50gQdViIKWjQ6I4zC5ELYLgBB0ETD/VBMP51LnybywJUKnZ6ow3VE/koImgfCl4Eiw=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "open": ["open@11.0.2", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "smol-toml": ["smol-toml@1.4.2", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@9.0.2", "", {}, "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ=="], + + "wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], + + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + } +} diff --git a/server/runtime/acp/codex/launch.mjs b/server/runtime/acp/codex/launch.mjs new file mode 100644 index 00000000..936db5ee --- /dev/null +++ b/server/runtime/acp/codex/launch.mjs @@ -0,0 +1,42 @@ +import { mkdir, readFile, readlink, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { adapter, aliases } from "./settings.mjs"; +import { run, platformEnvironment } from "./process.mjs"; + +process.umask(0o077); +const config = JSON.parse(await readFile(process.argv[2], "utf8")); +const bindings = JSON.parse(process.env.KERNEL_CODEX_BINDINGS); +const credential = (name) => { + const value = process.env[bindings[name]]; + if (!value) throw new Error("credential binding unavailable"); + return value; +}; +const home = process.env.CODEX_HOME; +await mkdir(process.env.HOME, { recursive: true, mode: 0o700 }); +await mkdir(home, { recursive: true, mode: 0o700 }); +const target = join(process.env.KERNEL_CODEX_STATE, "current", "config.toml"); +try { + await symlink(target, join(home, "config.toml")); +} catch (error) { + if (error.code !== "EEXIST" || await readlink(join(home, "config.toml")) !== target) throw error; +} +const env = { + ...platformEnvironment(), + CODEX_HOME: home, + CODEX_API_KEY: credential(config.launch.credential), + MODEL_PROVIDER: "openai", + INITIAL_AGENT_MODE: config.launch.mode, + NO_BROWSER: "1", +}; +for (const server of config.shared.mcpServers) { + for (const [name, alias] of aliases(server)) { + if (server.command) env[alias] = credential(server.envBindings[name]); + else { + const header = server.headerBindings[name]; + const value = (header.prefix ?? "") + credential(header.credential); + if (/[\r\n\0]/.test(value)) throw new Error("invalid MCP header credential"); + env[alias] = value; + } + } +} +run(process.execPath, [adapter], env); diff --git a/server/runtime/acp/codex/mcp-command.mjs b/server/runtime/acp/codex/mcp-command.mjs new file mode 100644 index 00000000..dac658ac --- /dev/null +++ b/server/runtime/acp/codex/mcp-command.mjs @@ -0,0 +1,9 @@ +import { run, platformEnvironment } from "./process.mjs"; + +const [mapping, command, ...args] = process.argv.slice(2); +const env = platformEnvironment(); +for (const [target, source] of JSON.parse(mapping)) { + if (!process.env[source]) throw new Error("MCP credential unavailable; reconnect to use the active configuration"); + env[target] = process.env[source]; +} +run(command, args, env); diff --git a/server/runtime/acp/codex/package.json b/server/runtime/acp/codex/package.json new file mode 100644 index 00000000..9960dc15 --- /dev/null +++ b/server/runtime/acp/codex/package.json @@ -0,0 +1,13 @@ +{ + "name": "kernel-codex-acp-runtime", + "private": true, + "type": "module", + "dependencies": { + "@agentclientprotocol/codex-acp": "1.10.0", + "@openai/codex": "0.153.3", + "smol-toml": "1.4.2" + }, + "overrides": { + "@openai/codex": "0.153.3" + } +} diff --git a/server/runtime/acp/codex/prepare.mjs b/server/runtime/acp/codex/prepare.mjs new file mode 100644 index 00000000..35f883e7 --- /dev/null +++ b/server/runtime/acp/codex/prepare.mjs @@ -0,0 +1,31 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { stringify } from "smol-toml"; +import { adapter, codex, settings } from "./settings.mjs"; + +process.umask(0o077); +const [revision] = process.argv.slice(2); +const config = JSON.parse(await readFile(join(revision, "config.json"), "utf8")); +for (const server of config.shared.mcpServers) { + if (server.command) await access(server.command, constants.X_OK); +} +const native = stringify(settings(config)); +await writeFile(join(revision, "config.toml"), native, { mode: 0o600 }); +// Validate using an isolated native home, without contacting providers or MCPs. +const probe = join(revision, "validation-home"); +await mkdir(probe, { mode: 0o700 }); +await writeFile(join(probe, "config.toml"), native, { mode: 0o600 }); +const options = { + cwd: revision, + env: { PATH: process.env.PATH, HOME: revision, CODEX_HOME: probe }, + timeout: 15000, + stdio: ["ignore", "pipe", "ignore"], + maxBuffer: 1 << 20, +}; +if (execFileSync(process.execPath, [adapter, "--version"], options).toString().trim() !== "@agentclientprotocol/codex-acp 1.10.0") + throw new Error("unexpected adapter version"); +if (execFileSync(process.execPath, [codex, "--version"], options).toString().trim() !== "codex-cli 0.153.3") + throw new Error("unexpected Codex version"); +execFileSync(process.execPath, [codex, "mcp", "list", "--json"], options); diff --git a/server/runtime/acp/codex/process.mjs b/server/runtime/acp/codex/process.mjs new file mode 100644 index 00000000..7bdc83b2 --- /dev/null +++ b/server/runtime/acp/codex/process.mjs @@ -0,0 +1,17 @@ +import { spawn } from "node:child_process"; + +export function run(command, args, env) { + const child = spawn(command, args, { 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)); +} + +export function platformEnvironment() { + return Object.fromEntries( + ["PATH", "HOME", "USER", "LANG", "TMPDIR", "TERM", "SSL_CERT_FILE", "SSL_CERT_DIR"] + .filter((name) => process.env[name] !== undefined) + .map((name) => [name, process.env[name]]), + ); +} diff --git a/server/runtime/acp/codex/settings.mjs b/server/runtime/acp/codex/settings.mjs new file mode 100644 index 00000000..700d82ea --- /dev/null +++ b/server/runtime/acp/codex/settings.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +export const adapter = fileURLToPath(new URL("./node_modules/@agentclientprotocol/codex-acp/dist/index.js", import.meta.url)); +export const codex = fileURLToPath(new URL("./node_modules/@openai/codex/bin/codex.js", import.meta.url)); + +// Bind aliases to the complete server definition: an old connection must not +// send its previous credential to a changed URL after shared settings activate. +export function aliases(server) { + const id = createHash("sha256").update(JSON.stringify(server)).digest("hex"); + const prefix = `KERNEL_CODEX_MCP_${id}_`; + return Object.keys(server.command ? server.envBindings ?? {} : server.headerBindings ?? {}) + .sort().map((name, index) => [name, prefix + index]); +} + +export function settings(config) { + return { + model: config.launch.model, + model_provider: "openai", + model_reasoning_effort: config.launch.thinking, + cli_auth_credentials_store: "ephemeral", + forced_login_method: "api", + web_search: config.shared.webSearch, + developer_instructions: config.shared.instructions ?? "", + analytics: { enabled: false }, + mcp_servers: Object.fromEntries(config.shared.mcpServers.map((server) => { + const names = aliases(server); + if (server.command) return [server.name, { + command: process.execPath, + args: [fileURLToPath(new URL("./mcp-command.mjs", import.meta.url)), JSON.stringify(names), server.command, ...(server.args ?? [])], + env_vars: names.map(([, name]) => name), + }]; + return [server.name, { + url: server.url, + env_http_headers: Object.fromEntries(names), + }]; + })), + }; +} diff --git a/server/runtime/acp/codex/settings.test.mjs b/server/runtime/acp/codex/settings.test.mjs new file mode 100644 index 00000000..368e11cd --- /dev/null +++ b/server/runtime/acp/codex/settings.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, mkdir, writeFile, copyFile, readlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { parse, stringify } from "smol-toml"; +import { aliases, settings } from "./settings.mjs"; + +const config = { + launch: { provider: "openai", model: "gpt-5.4-mini", thinking: "low", mode: "read-only", credential: "provider" }, + shared: { + instructions: 'Use "native" settings.\nNo invented escaping.\t', + webSearch: "disabled", + mcpServers: [ + { name: "stdio", command: "/bin/echo", args: ["hello"], envBindings: { TOKEN: "mcp" } }, + { name: "http", url: "https://example.com/mcp", transport: "http", headerBindings: { Authorization: { credential: "mcp", prefix: "Bearer " } } }, + ], + }, +}; + +test("native TOML round trips and contains references, never secret values", () => { + const native = parse(stringify(settings(config))); + assert.equal(native.model, config.launch.model); + assert.equal(native.developer_instructions, config.shared.instructions); + assert.equal(native.cli_auth_credentials_store, "ephemeral"); + assert.deepEqual(native.mcp_servers.stdio.env_vars, aliases(config.shared.mcpServers[0]).map(([, alias]) => alias)); + assert.deepEqual(native.mcp_servers.http.env_http_headers, Object.fromEntries(aliases(config.shared.mcpServers[1]))); +}); + +test("changed server destinations or bindings cannot reuse existing credential aliases", () => { + const server = config.shared.mcpServers[1]; + const before = aliases(server); + assert.notDeepEqual(aliases({ ...server, url: "https://other.example/mcp" }), before); + assert.notDeepEqual(aliases({ ...server, headerBindings: { Authorization: { credential: "different" } } }), before); +}); + +test("MCP stdio bindings remap without inheriting provider or unrelated credentials", () => { + const source = 'process.stdout.write(JSON.stringify(process.env))'; + const output = execFileSync(process.execPath, [new URL("./mcp-command.mjs", import.meta.url).pathname, + JSON.stringify([["TOKEN", "KERNEL_CODEX_MCP_TEST"]]), process.execPath, "-e", source], { + env: { PATH: process.env.PATH, KERNEL_CODEX_MCP_TEST: "mcp-fixture", CODEX_API_KEY: "provider-fixture", UNRELATED_SECRET: "unrelated-fixture" }, + }); + const env = JSON.parse(output); + assert.equal(env.TOKEN, "mcp-fixture"); + for (const name of ["CODEX_API_KEY", "UNRELATED_SECRET", "KERNEL_CODEX_MCP_TEST"]) assert.equal(env[name], undefined); +}); + +test("launch preserves native home, sanitizes bindings and rejects replaced native settings", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-launch-test-")); + try { + const runtime = join(root, "runtime"), state = join(root, "state"); + const adapterDir = join(runtime, "node_modules/@agentclientprotocol/codex-acp/dist"); + await mkdir(adapterDir, { recursive: true }); + for (const file of ["launch.mjs", "settings.mjs", "process.mjs"]) await copyFile(new URL(file, import.meta.url), join(runtime, file)); + await writeFile(join(adapterDir, "index.js"), 'process.stdout.write(JSON.stringify(process.env))'); + await mkdir(join(state, "current"), { recursive: true }); + const revision = join(state, "current/config.json"); + await writeFile(revision, JSON.stringify(config)); + const home = join(state, "native"); + const options = { env: { + PATH: process.env.PATH, HOME: join(state, "home"), CODEX_HOME: home, + KERNEL_CODEX_STATE: state, KERNEL_CODEX_BINDINGS: JSON.stringify({ provider: "KEY_SOURCE", mcp: "MCP_SOURCE" }), + KEY_SOURCE: "provider-fixture", MCP_SOURCE: "mcp-fixture", UNRELATED_SECRET: "unrelated-fixture", + APP_SERVER_LOGS: join(root, "must-not-log"), DEFAULT_AUTH_REQUEST: "must-not-inherit", + } }; + const run = () => JSON.parse(execFileSync(process.execPath, [join(runtime, "launch.mjs"), revision], options)); + const env = run(); + assert.equal(env.CODEX_API_KEY, "provider-fixture"); + assert.equal(env.CODEX_HOME, home); + for (const name of ["KEY_SOURCE", "MCP_SOURCE", "KERNEL_CODEX_BINDINGS", "UNRELATED_SECRET", "APP_SERVER_LOGS", "DEFAULT_AUTH_REQUEST"]) assert.equal(env[name], undefined); + assert.equal(env[aliases(config.shared.mcpServers[1])[0][1]], "Bearer mcp-fixture"); + assert.equal(await readlink(join(home, "config.toml")), join(state, "current/config.toml")); + await writeFile(join(home, "history-fixture"), "retain me"); + assert.equal(run().CODEX_HOME, home); + await rm(join(home, "config.toml")); + await writeFile(join(home, "config.toml"), "unmanaged"); + assert.throws(() => execFileSync(process.execPath, [join(runtime, "launch.mjs"), revision], { ...options, stdio: "ignore" })); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From e9c219d36265d4705b0d29971617394d64f5bf26 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:41:58 +0000 Subject: [PATCH 2/5] Prevent credential snapshots and strengthen native acceptance gates --- server/lib/agentproxy/CODEX.md | 44 +++++-- server/lib/agentproxy/codex_mcp_test.go | 128 +++++++++++++++++++ server/lib/agentproxy/testdata/codex_gate.py | 70 +++++++++- server/runtime/acp/codex/launch.mjs | 3 +- server/runtime/acp/codex/settings.mjs | 9 +- server/runtime/acp/codex/settings.test.mjs | 2 + 6 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 server/lib/agentproxy/codex_mcp_test.go diff --git a/server/lib/agentproxy/CODEX.md b/server/lib/agentproxy/CODEX.md index 9d219e81..89b35afb 100644 --- a/server/lib/agentproxy/CODEX.md +++ b/server/lib/agentproxy/CODEX.md @@ -37,7 +37,10 @@ Reauthenticate on each fresh connection. The catalog's `codex.credentials` can add operator-defined binding names; requests contain names, never secret values. Only referenced bindings reach the launch wrapper. It passes the provider key under `CODEX_API_KEY` and removes binding source names and ambient adapter -configuration/logging variables from the child environment. +configuration/logging variables from the child environment. Native shell snapshots +are disabled (`features.shell_snapshot` and `shell_snapshot_v2`): their environment +exports can persist provider/MCP keys. These flags and ephemeral auth storage are +also supplied as native session overrides so project config cannot re-enable them. ### Supported configuration @@ -46,9 +49,12 @@ configuration/logging variables from the child environment. - `model`: native Codex model ID. `thinking`: `minimal`, `low`, `medium`, `high`, `xhigh`; the selected model may accept only a subset. ACP config/model controls can change a session's selection. -- `mode`: `read-only`, `agent` (workspace-write), `agent-full-access`. These are - native adapter modes, not an additional Kernel sandbox. ACP owns permissions - and subsequent mode changes. +- `mode`: `read-only`, `agent`, `agent-full-access`. In this pinned adapter, + the misleading `read-only` ID means **workspace-write with user approvals** + ("Ask for approval"), not a read-only filesystem. `agent` uses workspace-write + with native automatic review; `agent-full-access` disables sandbox approvals. + These are native adapter modes, not an additional Kernel sandbox. ACP owns + permissions and subsequent mode changes. - `shared.instructions`: native `developer_instructions` (up to 64 KiB). `shared.webSearch`: native `web_search`, `disabled`, `cached` or `live`. - `shared.mcpServers`: native stdio and streamable HTTP (up to 32 servers). @@ -93,8 +99,10 @@ The common GET/PUT semantics apply: desired/effective revisions, safe failed preparation retention, required optimistic `If-Match`, 409 for stale or concurrent writes, private revision files and last-ready recovery after restart. Preparation is capped at 30 seconds, validates exact installed runtime versions and native -TOML using `codex mcp list --json`, and does not authenticate, start MCP servers or -call a provider. A ready revision is not proof of remote provider/MCP availability. +TOML loading using `codex mcp list --json`, and does not authenticate, start MCP +servers or call a provider. The native command is not strict schema/model +validation; Go validates the supported fields and enums first. A ready revision +is not proof of remote provider/MCP availability or model support. `/home/kernel/.agents/codex/native` is the stable `CODEX_HOME`; native sessions, SQLite state and history remain outside replaceable revisions. Its `config.toml` @@ -142,8 +150,12 @@ AGENT_PROXY_TEST_ACPREMOTE=/path/to/acp-venv/bin/acpremote \ Unit tests cover configuration validation, HTTP concurrency/failure/recovery, native preparation, private files, no persisted credentials, environment -isolation, native settings and safe credential-alias changes. The native test -skips explicitly without `AGENT_CODEX_TEST_RUNTIME`. CI installs both runtimes. +isolation, native settings and safe credential-alias changes. A real native +adapter/app-server test exercises streamable HTTP MCP initialize/tools-list with +a bound fixture header, without a model prompt, and checks native state for +fixture credentials even when project configuration requests shell snapshots. +Native tests skip explicitly without the corresponding runtime environment +variables. CI installs both runtimes. Opt-in paid gate, against a **fresh disposable image** with `OPENAI_API_KEY`: @@ -154,9 +166,13 @@ AGENT_API_URL=http://127.0.0.1:10001 \ This refuses to overwrite an existing configuration and uses small bounded `gpt-5.4-mini` turns. It tests independent connections, shared/session stdio MCP, -updates and failed preparation while connected, disconnect cleanup and fresh -list/load/history/model recall. Test transcripts are local private test evidence, -not proxy output persistence. HTTP MCP, media, native extensions, cancellation -mid-tool and provider/model combinations beyond the stated gates need separate -validation. Packaged headless testing does not validate the platform gateway, -TLS/authentication boundary, arm64 or headful desktop behavior. +updates and failed preparation while connected, disconnect cleanup, absence of +persisted provider credentials and fresh paginated list/load/history/model recall. +To force two native discovery pages without 26 extra paid turns, the gate creates +26 native forks and seeds only those pagination fixtures with a user event and +preview in the pinned native storage format. The original real session and its +history are never modified by fixture seeding. Test transcripts are local private +evidence, not proxy output persistence. HTTP MCP tool invocation, media, native +extensions, cancellation mid-tool and provider/model combinations beyond the +stated gates need separate validation. Packaged headless testing does not validate +the platform gateway, TLS/authentication boundary, arm64 or headful desktop behavior. diff --git a/server/lib/agentproxy/codex_mcp_test.go b/server/lib/agentproxy/codex_mcp_test.go new file mode 100644 index 00000000..0c6538c7 --- /dev/null +++ b/server/lib/agentproxy/codex_mcp_test.go @@ -0,0 +1,128 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" +) + +// Only initialize/authenticate/new: native MCP discovery, no model prompt or paid provider call. +func TestCodexNativeHTTPMCP(t *testing.T) { + runtime, remote := os.Getenv("AGENT_CODEX_TEST_RUNTIME"), os.Getenv("AGENT_PROXY_TEST_ACPREMOTE") + if runtime == "" || remote == "" { + t.Skip("set AGENT_CODEX_TEST_RUNTIME and AGENT_PROXY_TEST_ACPREMOTE to installed pinned runtimes") + } + var initialized, listed atomic.Bool + mcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer mcp-fixture" { + http.Error(w, "missing bound header", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + 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 { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if len(request.ID) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + var result any + switch request.Method { + case "initialize": + initialized.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"}} + case "tools/list": + listed.Store(true) + result = map[string]any{"tools": []any{}} + default: + result = map[string]any{} + } + 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() + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + t.Setenv("CODEX_TEST_KEY", "provider-fixture") + t.Setenv("CODEX_TEST_MCP_KEY", "mcp-fixture") + p := &CodexOptions{StateDir: t.TempDir(), RuntimeDir: runtime, Node: node, Credentials: map[string]string{"provider": "CODEX_TEST_KEY", "mcp": "CODEX_TEST_MCP_KEY"}} + ctx, cancel := context.WithCancel(context.Background()) + h, err := New(ctx, Config{ACPRemote: remote, MaxConnections: 1, Codex: p}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + cancel() + t.Fatal(err) + } + server := httptest.NewServer(h) + defer func() { cancel(); server.Close(); eventually(t, func() bool { return len(h.slots) == 0 }) }() + c := codexDesired() + c.Shared.MCPServers = []ManagedMCPServer{{Name: "docs", URL: mcp.URL, Transport: "http", HeaderBindings: map[string]CredentialHeader{"Authorization": {Credential: "mcp", Prefix: "Bearer "}}}} + data, _ := json.Marshal(c) + if err := h.codex.apply(ctx, "0", data); err != nil { + t.Fatal(err) + } + conn := connect(t, "ws"+strings.TrimPrefix(server.URL, "http")+"/agent/v1/acp?harness=codex") + defer conn.CloseNow() + request := func(id int, method string, params any) { + send(t, conn, map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + for { + message := read(t, conn) + if string(message.ID) == strconv.Itoa(id) { + return + } + } + } + request(1, "initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}}) + request(2, "authenticate", map[string]string{"methodId": "api-key"}) + workspace := t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, ".codex"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, ".codex", "config.toml"), []byte("[features]\nshell_snapshot = true\nshell_snapshot_v2 = true\n"), 0600); err != nil { + t.Fatal(err) + } + request(3, "session/new", map[string]any{"cwd": workspace, "mcpServers": []any{}}) + eventually(t, func() bool { return initialized.Load() && listed.Load() }) + conn.CloseNow() + eventually(t, func() bool { return len(h.slots) == 0 }) + if err := filepath.WalkDir(p.StateDir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.Type().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), "provider-fixture") || strings.Contains(string(data), "mcp-fixture") { + t.Errorf("native state persisted a credential in %s", path) + } + return nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/server/lib/agentproxy/testdata/codex_gate.py b/server/lib/agentproxy/testdata/codex_gate.py index cab21415..9453dd11 100644 --- a/server/lib/agentproxy/testdata/codex_gate.py +++ b/server/lib/agentproxy/testdata/codex_gate.py @@ -30,13 +30,13 @@ def http(method, path, data=None, revision=None): def pids(): - code = """import pathlib,json + code = "import pathlib,json\nmcp = " + repr((workspace + "/mcp.py").encode()) + "\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 any(a.startswith(b'/opt/kernel-agent/codex/') for a in args):pids.append(path.name) + if mcp in args or any(a.startswith(b'/opt/kernel-agent/codex/') for a in args):pids.append(path.name) print(json.dumps(pids)) """ status, result = http( @@ -48,6 +48,25 @@ def pids(): return set(json.loads(base64.b64decode(result["stdout_b64"]))) +def native_settings(): + code = """import pathlib,hashlib +root=pathlib.Path('/home/kernel/.agents/codex') +assert not (root/'native/auth.json').exists(), 'native auth file unexpectedly exists' +assert (root/'native/config.toml').readlink() == root/'current/config.toml' +print(hashlib.sha256((root/'current/config.toml').read_bytes()).hexdigest()) +""" + status, result = http("POST", "/process/exec", { + "command": "/opt/kernel-agent/venv/bin/python", "args": ["-c", code] + }) + assert status == 200 and result["exit_code"] == 0, "native settings/auth check failed" + return base64.b64decode(result["stdout_b64"]).decode().strip() + + +def assert_selection(session): + assert session["models"]["currentModelId"] == "gpt-5.4-mini[low]" + assert session["modes"]["currentModeId"] == "read-only" + + config_path = "/agent/v1/harnesses/codex/config" status, initial = http("GET", config_path) assert status == 200 @@ -178,6 +197,8 @@ async def main(): session, _ = await first.call( "session/new", {"cwd": workspace, "mcpServers": []} ) + assert_selection(session) + original_settings = native_settings() sid = session["sessionId"] old = pids() assert old @@ -217,6 +238,7 @@ async def main(): }) assert status == 200 and calls["exit_code"] == 0 assert set(base64.b64decode(calls["stdout_b64"]).decode().splitlines()) == {"shared", "session"} + assert native_settings() == original_settings, "session changed managed native settings" updated = json.loads(json.dumps(desired)) updated["shared"]["mcpServers"][0]["args"][1] = "updated-shared" active = pids() @@ -224,6 +246,8 @@ async def main(): http, "PUT", config_path, updated, ready["revision"] ) assert code == 200, (code, latest) + updated_settings = native_settings() + assert updated_settings != original_settings assert pids() == active, "configuration update restarted active connections" code, _ = http("PUT", config_path, updated, ready["revision"]) assert code == 409, "stale If-Match accepted" @@ -238,15 +262,33 @@ async def main(): failed["status"] == "failed" and failed["effectiveRevision"] == latest["revision"] ) - # Native forks persist real history without additional provider turns. - # More than the native 25-item page size forces cursor traversal. + # No-provider native forks supply padding for a second discovery page. + # Codex hides forks without their own user event; seed a fixture event + # and preview only on these IDs, never alter the original real history. + padding = [] for _ in range(26): forked, _ = await first.call("session/fork", { "sessionId": sid, "cwd": workspace, "mcpServers": [] }) + padding.append(forked["sessionId"]) await first.call("session/close", {"sessionId": forked["sessionId"]}) await first.close() assert not old.intersection(pids()), (old, pids()) + seed = """import sqlite3,sys,json,datetime +with sqlite3.connect('/home/kernel/.agents/codex/native/state_5.sqlite') as db: + for sid in json.loads(sys.argv[1]): + changed = db.execute("UPDATE threads SET preview='pagination fixture' WHERE id=? AND preview=''", (sid,)).rowcount + assert changed == 1 + path = db.execute('SELECT rollout_path FROM threads WHERE id=?', (sid,)).fetchone()[0] + event = {'timestamp':datetime.datetime.now(datetime.UTC).isoformat(), 'type':'event_msg', + 'payload':{'type':'user_message','message':'pagination fixture','images':[]}} + with open(path,'a') as f:f.write(json.dumps(event)+chr(10)) +""" + status, seeded = http("POST", "/process/exec", { + "command": "/opt/kernel-agent/venv/bin/python", + "args": ["-c", seed, json.dumps(padding)], + }) + assert status == 200 and seeded["exit_code"] == 0 text = await second.prompt( other["sessionId"], "Reply only with OK. Do not use tools." ) @@ -267,9 +309,10 @@ async def main(): assert pages < 100, "pagination did not terminate" assert pages >= 2, "native history did not exercise pagination" assert any(s["sessionId"] == sid for s in sessions) - _, history = await reconnected.call( + loaded, history = await reconnected.call( "session/load", {"sessionId": sid, "cwd": workspace, "mcpServers": []} ) + assert_selection(loaded) assert marker in json.dumps(history) text = await reconnected.prompt( sid, @@ -281,6 +324,7 @@ async def main(): "Call the checkpoint MCP tool once and return its output. Do not use other tools.", ) assert "updated-shared" in text, text + assert native_settings() == updated_settings, "load/prompt changed managed native settings" summary = { "configurationUpdateKeepsConnections": True, "updatedSharedMCP": True, @@ -291,13 +335,29 @@ async def main(): "freshListLoadHistoryAndRecall": True, "sharedMCP": True, "sessionMCPAddition": True, + "noPersistedProviderCredential": True, + "nativeModelAndMode": True, + "nativeSettingsPreserved": True, "listPages": pages, + "paginationFixtureSessions": len(padding), "sessionId": sid, } finally: for c in clients: await c.close() assert not pids(), pids() + check = """import os,pathlib +key=os.environ['OPENAI_API_KEY'].encode() +root=pathlib.Path('/home/kernel/.agents/codex') +assert not (root/'native/auth.json').exists(), 'API key persisted in native auth file' +for path in root.rglob('*'): + if path.is_file(): + assert key not in path.read_bytes(), 'provider credential persisted in managed state' +""" + status, checked = http("POST", "/process/exec", { + "command": "/opt/kernel-agent/venv/bin/python", "args": ["-c", check] + }) + assert status == 200 and checked["exit_code"] == 0, "persisted-credential check failed" (root / "summary.json").write_text(json.dumps(summary, indent=2)) (root / "events.json").write_text( json.dumps([c.events for c in clients], indent=2) diff --git a/server/runtime/acp/codex/launch.mjs b/server/runtime/acp/codex/launch.mjs index 936db5ee..24b310c6 100644 --- a/server/runtime/acp/codex/launch.mjs +++ b/server/runtime/acp/codex/launch.mjs @@ -1,6 +1,6 @@ import { mkdir, readFile, readlink, symlink } from "node:fs/promises"; import { join } from "node:path"; -import { adapter, aliases } from "./settings.mjs"; +import { adapter, aliases, safetySettings } from "./settings.mjs"; import { run, platformEnvironment } from "./process.mjs"; process.umask(0o077); @@ -23,6 +23,7 @@ try { const env = { ...platformEnvironment(), CODEX_HOME: home, + CODEX_CONFIG: JSON.stringify(safetySettings), CODEX_API_KEY: credential(config.launch.credential), MODEL_PROVIDER: "openai", INITIAL_AGENT_MODE: config.launch.mode, diff --git a/server/runtime/acp/codex/settings.mjs b/server/runtime/acp/codex/settings.mjs index 700d82ea..2b779bf7 100644 --- a/server/runtime/acp/codex/settings.mjs +++ b/server/runtime/acp/codex/settings.mjs @@ -13,12 +13,19 @@ export function aliases(server) { .sort().map((name, index) => [name, prefix + index]); } +// Native shell snapshots export the process environment, including bound keys. +// Also enforce these settings as session overrides against project config. +export const safetySettings = { + cli_auth_credentials_store: "ephemeral", + features: { shell_snapshot: false, shell_snapshot_v2: false }, +}; + export function settings(config) { return { model: config.launch.model, model_provider: "openai", model_reasoning_effort: config.launch.thinking, - cli_auth_credentials_store: "ephemeral", + ...safetySettings, forced_login_method: "api", web_search: config.shared.webSearch, developer_instructions: config.shared.instructions ?? "", diff --git a/server/runtime/acp/codex/settings.test.mjs b/server/runtime/acp/codex/settings.test.mjs index 368e11cd..b0012de0 100644 --- a/server/runtime/acp/codex/settings.test.mjs +++ b/server/runtime/acp/codex/settings.test.mjs @@ -24,6 +24,7 @@ test("native TOML round trips and contains references, never secret values", () assert.equal(native.model, config.launch.model); assert.equal(native.developer_instructions, config.shared.instructions); assert.equal(native.cli_auth_credentials_store, "ephemeral"); + assert.deepEqual(native.features, { shell_snapshot: false, shell_snapshot_v2: false }); assert.deepEqual(native.mcp_servers.stdio.env_vars, aliases(config.shared.mcpServers[0]).map(([, alias]) => alias)); assert.deepEqual(native.mcp_servers.http.env_http_headers, Object.fromEntries(aliases(config.shared.mcpServers[1]))); }); @@ -68,6 +69,7 @@ test("launch preserves native home, sanitizes bindings and rejects replaced nati const env = run(); assert.equal(env.CODEX_API_KEY, "provider-fixture"); assert.equal(env.CODEX_HOME, home); + assert.deepEqual(JSON.parse(env.CODEX_CONFIG).features, { shell_snapshot: false, shell_snapshot_v2: false }); for (const name of ["KEY_SOURCE", "MCP_SOURCE", "KERNEL_CODEX_BINDINGS", "UNRELATED_SECRET", "APP_SERVER_LOGS", "DEFAULT_AUTH_REQUEST"]) assert.equal(env[name], undefined); assert.equal(env[aliases(config.shared.mcpServers[1])[0][1]], "Bearer mcp-fixture"); assert.equal(await readlink(join(home, "config.toml")), join(state, "current/config.toml")); From 5f1d016ef61b72be866182f6c9fc282dc7be6450 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:45:35 +0000 Subject: [PATCH 3/5] Preserve all valid MCP environment binding names --- server/runtime/acp/codex/mcp-command.mjs | 2 +- server/runtime/acp/codex/settings.test.mjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/runtime/acp/codex/mcp-command.mjs b/server/runtime/acp/codex/mcp-command.mjs index dac658ac..52a6acfe 100644 --- a/server/runtime/acp/codex/mcp-command.mjs +++ b/server/runtime/acp/codex/mcp-command.mjs @@ -1,7 +1,7 @@ import { run, platformEnvironment } from "./process.mjs"; const [mapping, command, ...args] = process.argv.slice(2); -const env = platformEnvironment(); +const env = Object.assign(Object.create(null), platformEnvironment()); for (const [target, source] of JSON.parse(mapping)) { if (!process.env[source]) throw new Error("MCP credential unavailable; reconnect to use the active configuration"); env[target] = process.env[source]; diff --git a/server/runtime/acp/codex/settings.test.mjs b/server/runtime/acp/codex/settings.test.mjs index b0012de0..9f8cec1e 100644 --- a/server/runtime/acp/codex/settings.test.mjs +++ b/server/runtime/acp/codex/settings.test.mjs @@ -39,11 +39,12 @@ test("changed server destinations or bindings cannot reuse existing credential a test("MCP stdio bindings remap without inheriting provider or unrelated credentials", () => { const source = 'process.stdout.write(JSON.stringify(process.env))'; const output = execFileSync(process.execPath, [new URL("./mcp-command.mjs", import.meta.url).pathname, - JSON.stringify([["TOKEN", "KERNEL_CODEX_MCP_TEST"]]), process.execPath, "-e", source], { + JSON.stringify([["TOKEN", "KERNEL_CODEX_MCP_TEST"], ["__proto__", "KERNEL_CODEX_MCP_TEST"]]), process.execPath, "-e", source], { env: { PATH: process.env.PATH, KERNEL_CODEX_MCP_TEST: "mcp-fixture", CODEX_API_KEY: "provider-fixture", UNRELATED_SECRET: "unrelated-fixture" }, }); const env = JSON.parse(output); assert.equal(env.TOKEN, "mcp-fixture"); + assert.equal(env.__proto__, "mcp-fixture"); for (const name of ["CODEX_API_KEY", "UNRELATED_SECRET", "KERNEL_CODEX_MCP_TEST"]) assert.equal(env[name], undefined); }); From 11ad3651910627ef298597c5c28f32336eed93d2 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:54:10 +0000 Subject: [PATCH 4/5] Disable native plugin sync and clarify security test scope --- server/lib/agentproxy/CODEX.md | 12 ++++++++---- server/lib/agentproxy/codex_mcp_test.go | 5 ++++- server/lib/agentproxy/testdata/codex_gate.py | 2 ++ server/runtime/acp/codex/settings.mjs | 4 ++-- server/runtime/acp/codex/settings.test.mjs | 4 ++-- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/server/lib/agentproxy/CODEX.md b/server/lib/agentproxy/CODEX.md index 89b35afb..cb7af57f 100644 --- a/server/lib/agentproxy/CODEX.md +++ b/server/lib/agentproxy/CODEX.md @@ -41,6 +41,8 @@ configuration/logging variables from the child environment. Native shell snapsho are disabled (`features.shell_snapshot` and `shell_snapshot_v2`): their environment exports can persist provider/MCP keys. These flags and ephemeral auth storage are also supplied as native session overrides so project config cannot re-enable them. +`features.plugins=false` disables native plugins and their automatic unpinned +catalog clone/recommendations; native project skills remain available. ### Supported configuration @@ -152,10 +154,12 @@ Unit tests cover configuration validation, HTTP concurrency/failure/recovery, native preparation, private files, no persisted credentials, environment isolation, native settings and safe credential-alias changes. A real native adapter/app-server test exercises streamable HTTP MCP initialize/tools-list with -a bound fixture header, without a model prompt, and checks native state for -fixture credentials even when project configuration requests shell snapshots. -Native tests skip explicitly without the corresponding runtime environment -variables. CI installs both runtimes. +a bound fixture header, without a model prompt. It checks plugin sync is disabled +and scans the resulting state for fixture keys. This handshake-only test does not +reproduce native shell snapshot creation: the paid gate checks absence of the +snapshot directory and scans all managed files after real turns. Node tests assert +the safety flags in native config and session overrides. Native tests skip +explicitly without the corresponding runtime variables. CI installs both runtimes. Opt-in paid gate, against a **fresh disposable image** with `OPENAI_API_KEY`: diff --git a/server/lib/agentproxy/codex_mcp_test.go b/server/lib/agentproxy/codex_mcp_test.go index 0c6538c7..5748d7a7 100644 --- a/server/lib/agentproxy/codex_mcp_test.go +++ b/server/lib/agentproxy/codex_mcp_test.go @@ -100,13 +100,16 @@ func TestCodexNativeHTTPMCP(t *testing.T) { if err := os.Mkdir(filepath.Join(workspace, ".codex"), 0700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(workspace, ".codex", "config.toml"), []byte("[features]\nshell_snapshot = true\nshell_snapshot_v2 = true\n"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(workspace, ".codex", "config.toml"), []byte("[features]\nshell_snapshot = true\nshell_snapshot_v2 = true\nplugins = true\n"), 0600); err != nil { t.Fatal(err) } request(3, "session/new", map[string]any{"cwd": workspace, "mcpServers": []any{}}) eventually(t, func() bool { return initialized.Load() && listed.Load() }) conn.CloseNow() eventually(t, func() bool { return len(h.slots) == 0 }) + if _, err := os.Stat(filepath.Join(p.StateDir, "native", ".tmp", "plugins")); !os.IsNotExist(err) { + t.Fatal("native plugin catalog sync was not disabled") + } if err := filepath.WalkDir(p.StateDir, func(path string, entry os.DirEntry, err error) error { if err != nil { return err diff --git a/server/lib/agentproxy/testdata/codex_gate.py b/server/lib/agentproxy/testdata/codex_gate.py index 9453dd11..a09e4a48 100644 --- a/server/lib/agentproxy/testdata/codex_gate.py +++ b/server/lib/agentproxy/testdata/codex_gate.py @@ -350,6 +350,8 @@ async def main(): key=os.environ['OPENAI_API_KEY'].encode() root=pathlib.Path('/home/kernel/.agents/codex') assert not (root/'native/auth.json').exists(), 'API key persisted in native auth file' +assert not (root/'native/shell_snapshots').exists(), 'native shell snapshots unexpectedly enabled' +assert not (root/'native/.tmp/plugins').exists(), 'native plugin catalog unexpectedly synced' for path in root.rglob('*'): if path.is_file(): assert key not in path.read_bytes(), 'provider credential persisted in managed state' diff --git a/server/runtime/acp/codex/settings.mjs b/server/runtime/acp/codex/settings.mjs index 2b779bf7..a9435897 100644 --- a/server/runtime/acp/codex/settings.mjs +++ b/server/runtime/acp/codex/settings.mjs @@ -13,11 +13,11 @@ export function aliases(server) { .sort().map((name, index) => [name, prefix + index]); } -// Native shell snapshots export the process environment, including bound keys. +// Disable credential-bearing shell snapshots and unpinned plugin catalog sync. // Also enforce these settings as session overrides against project config. export const safetySettings = { cli_auth_credentials_store: "ephemeral", - features: { shell_snapshot: false, shell_snapshot_v2: false }, + features: { shell_snapshot: false, shell_snapshot_v2: false, plugins: false }, }; export function settings(config) { diff --git a/server/runtime/acp/codex/settings.test.mjs b/server/runtime/acp/codex/settings.test.mjs index 9f8cec1e..12ded5e6 100644 --- a/server/runtime/acp/codex/settings.test.mjs +++ b/server/runtime/acp/codex/settings.test.mjs @@ -24,7 +24,7 @@ test("native TOML round trips and contains references, never secret values", () assert.equal(native.model, config.launch.model); assert.equal(native.developer_instructions, config.shared.instructions); assert.equal(native.cli_auth_credentials_store, "ephemeral"); - assert.deepEqual(native.features, { shell_snapshot: false, shell_snapshot_v2: false }); + assert.deepEqual(native.features, { shell_snapshot: false, shell_snapshot_v2: false, plugins: false }); assert.deepEqual(native.mcp_servers.stdio.env_vars, aliases(config.shared.mcpServers[0]).map(([, alias]) => alias)); assert.deepEqual(native.mcp_servers.http.env_http_headers, Object.fromEntries(aliases(config.shared.mcpServers[1]))); }); @@ -70,7 +70,7 @@ test("launch preserves native home, sanitizes bindings and rejects replaced nati const env = run(); assert.equal(env.CODEX_API_KEY, "provider-fixture"); assert.equal(env.CODEX_HOME, home); - assert.deepEqual(JSON.parse(env.CODEX_CONFIG).features, { shell_snapshot: false, shell_snapshot_v2: false }); + assert.deepEqual(JSON.parse(env.CODEX_CONFIG).features, { shell_snapshot: false, shell_snapshot_v2: false, plugins: false }); for (const name of ["KEY_SOURCE", "MCP_SOURCE", "KERNEL_CODEX_BINDINGS", "UNRELATED_SECRET", "APP_SERVER_LOGS", "DEFAULT_AUTH_REQUEST"]) assert.equal(env[name], undefined); assert.equal(env[aliases(config.shared.mcpServers[1])[0][1]], "Bearer mcp-fixture"); assert.equal(await readlink(join(home, "config.toml")), join(state, "current/config.toml")); From 564b646be5fdbed85f4e410e35be1040a127b8cf Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:57:37 +0000 Subject: [PATCH 5/5] Detect plugin sync before its clone completes --- server/lib/agentproxy/codex_mcp_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/agentproxy/codex_mcp_test.go b/server/lib/agentproxy/codex_mcp_test.go index 5748d7a7..e7a62250 100644 --- a/server/lib/agentproxy/codex_mcp_test.go +++ b/server/lib/agentproxy/codex_mcp_test.go @@ -107,7 +107,7 @@ func TestCodexNativeHTTPMCP(t *testing.T) { eventually(t, func() bool { return initialized.Load() && listed.Load() }) conn.CloseNow() eventually(t, func() bool { return len(h.slots) == 0 }) - if _, err := os.Stat(filepath.Join(p.StateDir, "native", ".tmp", "plugins")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(p.StateDir, "native", ".tmp")); !os.IsNotExist(err) { t.Fatal("native plugin catalog sync was not disabled") } if err := filepath.WalkDir(p.StateDir, func(path string, entry os.DirEntry, err error) error {