From ecb00db6296b4af6f51f2da96858875c188b4025 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 02:45:36 +0000 Subject: [PATCH 1/2] Declare the Codex agent-host view and view Executor Codex declares a View when discovery finds the pinned release as a static binary: its install directory as the closure, codex and the code-mode host as LocalExec, a git shim, /bin/bash as the passwd shell, /etc/codex masked, and the proxy variables pointed at the Session proxy. The view Executor lays CODEX_HOME and TMPDIR out in the Session home, writes the gateway provider with the placeholder bearer, configures MCP only from the Session bindings, disables the features that run local programs, and launches app-server through the Session with a closed environment. --- .../internal/agent/codex/declaration.go | 1 + .../internal/agent/codex/declaration_test.go | 2 +- apps/daemon/internal/agent/codex/mcp_http.go | 8 +- .../internal/agent/codex/model_verbosity.go | 22 +- apps/daemon/internal/agent/codex/options.go | 37 +-- .../internal/agent/codex/preparation.go | 13 +- apps/daemon/internal/agent/codex/recovery.go | 2 +- .../internal/agent/codex/recovery_test.go | 3 +- apps/daemon/internal/agent/codex/rpc.go | 14 +- apps/daemon/internal/agent/codex/session.go | 5 +- .../internal/agent/codex/session_plan.go | 2 +- .../agent/codex/subagent_observations_test.go | 3 +- .../internal/agent/codex/subagent_rollout.go | 16 +- .../agent/codex/subagent_rollout_test.go | 6 +- apps/daemon/internal/agent/codex/view.go | 249 ++++++++++++++++++ apps/daemon/internal/agent/codex/view_test.go | 97 +++++++ contracts/agents-api/README.md | 1 + 17 files changed, 440 insertions(+), 41 deletions(-) create mode 100644 apps/daemon/internal/agent/codex/view.go create mode 100644 apps/daemon/internal/agent/codex/view_test.go diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 7020b66c0..d38c53382 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -65,6 +65,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) runtime.Executor = NewExecutorFactory() + runtime.View = discoverView(version) if caps.LocalEnvironment.IsSupported() { runtime.WorkspaceReadPreparation = true runtime.Preparation = func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index 652c5cc1e..298554b0a 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -49,7 +49,7 @@ func TestDeclaredCapabilityBaseline(t *testing.T) { func TestUnavailableRuntimeHasNoExecutionFactories(t *testing.T) { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return "", errors.New("missing") }) - if runtime.Info.Available || runtime.Executor != nil || runtime.Preparation != nil || runtime.Session == nil { + if runtime.Info.Available || runtime.Executor != nil || runtime.Preparation != nil || runtime.View != nil || runtime.Session == nil { t.Fatalf("unavailable runtime: %+v", runtime) } } diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index bdeb2af17..d74292b7b 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -22,6 +22,12 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf if bindings == nil { return nil, nil, nil } + return mcpServersFromBindings(bindings) +} + +// mcpServersFromBindings renders resolved bindings, with each credential in a +// private environment variable. +func mcpServersFromBindings(bindings []agent.MCPBinding) (map[string]mcpServerConfig, []string, error) { servers := make(map[string]mcpServerConfig, len(bindings)) var env []string for _, binding := range bindings { @@ -50,7 +56,7 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf } func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { - codexHome := nativeHomeFromPlan(*plan) + codexHome := plan.home.Host if !filepath.IsAbs(codexHome) { return errors.New("codex: public MCP requires a private native home") } diff --git a/apps/daemon/internal/agent/codex/model_verbosity.go b/apps/daemon/internal/agent/codex/model_verbosity.go index 6c9d4f28e..0916691cc 100644 --- a/apps/daemon/internal/agent/codex/model_verbosity.go +++ b/apps/daemon/internal/agent/codex/model_verbosity.go @@ -10,9 +10,20 @@ import ( "strings" ) +// catalogProbe runs `debug models` on the trusted install, outside any view. +type catalogProbe struct { + binary string + dir string + env []string +} + // Validate against the binary's active catalog and use that same snapshot for // execution. A CLI override alone is silently ignored for unsupported models. func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan) error { + return verifyModelVerbosity(ctx, catalogProbe{binary: binary, dir: plan.Cwd, env: append(os.Environ(), plan.Env...)}, plan) +} + +func verifyModelVerbosity(ctx context.Context, probe catalogProbe, plan *SessionPlan) error { args := []string{} for _, kv := range plan.ExtraConfig { args = append(args, "-c", kv[0]+"="+kv[1]) @@ -20,12 +31,12 @@ func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan args = append(args, "debug", "models") ctx, cancel := context.WithTimeout(ctx, rpcDefaultRequestTimeout) defer cancel() - cmd, err := modelCatalogCommand(ctx, binary, args...) + cmd, err := modelCatalogCommand(ctx, probe.binary, args...) if err != nil { return err } - cmd.Dir = plan.Cwd - cmd.Env = append(os.Environ(), plan.Env...) + cmd.Dir = probe.dir + cmd.Env = probe.env catalog, err := cmd.Output() // The launcher can exit before its children, ending the context watcher. if cmd.Process != nil { @@ -45,7 +56,7 @@ func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan // Protocol medium means the default text amount, which needs no native override. plan.ExtraConfig = slices.DeleteFunc(plan.ExtraConfig, func(kv [2]string) bool { return kv[0] == "model_verbosity" }) } - codexHome := nativeHomeFromPlan(*plan) + codexHome := plan.home.Host if !filepath.IsAbs(codexHome) { return fmt.Errorf("codex: missing managed home for model catalog") } @@ -64,7 +75,8 @@ func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan } cleanup := plan.Cleanup plan.Cleanup = func() { _ = os.Remove(file.Name()); cleanup() } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(file.Name())}) + // Codex reads the catalog at its own path for the same file. + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(plan.home.View + strings.TrimPrefix(file.Name(), codexHome))}) return nil } diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 54defb0c9..96c9b1672 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" ) @@ -21,9 +22,9 @@ type SessionPlan struct { // the Session's private CODEX_HOME for environment:none. Cwd string - // Env is the full environment slice (KEY=value) to layer onto - // os.Environ() before spawning. Includes CODEX_HOME, plus any - // caller-provided OPENAI_API_KEY / CODEX_API_KEY / proxy vars. + // Env is the environment slice (KEY=value) the plan adds. A local + // codex layers it onto os.Environ(); in an agent-host view it is the + // complete environment. Includes CODEX_HOME. Env []string // ExtraConfig is a list of `-c key=value` overrides applied at the @@ -39,6 +40,9 @@ type SessionPlan struct { // Non-nil for declared service or Environment MCP, including private references. mcpServers map[string]mcpServerConfig + // home is CODEX_HOME as the daemon writes it and as codex sees it. + home agent.ViewDir + // Model is the slug to request on thread/start. Empty inherits the // codex.config.toml default. Model string @@ -95,6 +99,15 @@ type SessionPlan struct { // // Daemon-managed Codex sessions bypass approvals and the engine sandbox. func BuildSessionPlan(runID, agentStateKey string, opts map[string]any) (SessionPlan, error) { + return buildSessionPlan(opts, func() (agent.ViewDir, error) { + home, err := allocCodexHome(agentStateKey) + return agent.ViewDir{Host: home, View: home}, err + }) +} + +// buildSessionPlan derives the plan with CODEX_HOME from allocHome, which runs +// only after the options validate. +func buildSessionPlan(opts map[string]any, allocHome func() (agent.ViewDir, error)) (SessionPlan, error) { cleanup := func() {} plan := SessionPlan{ CollaborationMode: CollaborationModeDefault, @@ -147,14 +160,16 @@ func BuildSessionPlan(runID, agentStateKey string, opts map[string]any) (Session return plan, err } - codexHome, err := allocCodexHome(agentStateKey) + home, err := allocHome() if err != nil { return plan, err } + codexHome := home.Host if err := resetGeneratedConfig(codexHome); err != nil { return plan, err } - env = append(env, "CODEX_HOME="+codexHome) + env = append(env, "CODEX_HOME="+home.View) + plan.home = home // MCP servers come pre-rendered from server/internal/connector/agentdaemon // (capabilityAdditions.MCPServers, rendered via render.TargetCodex) @@ -233,18 +248,6 @@ func allocCodexHome(agentStateKey string) (string, error) { return dir, nil } -// nativeHomeFromPlan returns the CODEX_HOME codex receives: os/exec keeps the -// last duplicate entry, and BuildSessionPlan appends the allocated home last. -func nativeHomeFromPlan(plan SessionPlan) string { - var home string - for _, entry := range plan.Env { - if value, ok := strings.CutPrefix(entry, "CODEX_HOME="); ok { - home = value - } - } - return home -} - func resetGeneratedConfig(codexHome string) error { path := filepath.Join(codexHome, "config.toml") if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 44d750c04..1998f2524 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -64,7 +64,13 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg req.AgentStateKey = effectiveAgentStateKey(req) req.AgentOptions = executionOptions(req) - plan, skillRoots, err := prepareSessionPlan(parent, req, cfg) + var plan SessionPlan + var skillRoots []string + if cfg.view != nil { + plan, err = prepareViewPlan(parent, req, cfg) + } else { + plan, skillRoots, err = prepareSessionPlan(parent, req, cfg) + } if err != nil { return nil, err } @@ -80,6 +86,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg LogTag: "codex-preparation", Logger: cfg.logger, } + if cfg.view != nil { + rpcCfg.Binary, rpcCfg.Env, rpcCfg.Launch = cfg.view.binary, plan.Env, cfg.view.Launch + } for _, kv := range plan.ExtraConfig { rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) } @@ -87,7 +96,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg rpc := NewJSONRPCClient(rpcCfg) s := &Session{ - nativeHome: nativeHomeFromPlan(plan), + nativeHome: plan.home, functions: functions, observeMessages: req.ObserveMessages, diff --git a/apps/daemon/internal/agent/codex/recovery.go b/apps/daemon/internal/agent/codex/recovery.go index 1f787cf25..79bd4a760 100644 --- a/apps/daemon/internal/agent/codex/recovery.go +++ b/apps/daemon/internal/agent/codex/recovery.go @@ -13,7 +13,7 @@ func SupportsNativeSessionRecovery(version string) bool { } func (s *Session) recoverRoot(plan SessionPlan) (string, error) { - home := nativeHomeFromPlan(plan) + home := plan.home.View if !filepath.IsAbs(home) || !filepath.IsAbs(plan.Cwd) { return "", errors.New("codex: recovery requires private native history") } diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index da32ad87e..0fba69762 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" @@ -30,7 +31,7 @@ func TestRequiredHistoryResolution(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) defer cancel() home := t.TempDir() - plan := SessionPlan{Cwd: "/workspace", Env: []string{"CODEX_HOME=" + home}} + plan := SessionPlan{Cwd: "/workspace", home: agent.ViewDir{Host: home, View: home}} row := map[string]any{"id": "original", "parentThreadId": nil, "forkedFromId": nil, "ephemeral": false, "source": "vscode", "cwd": plan.Cwd, "path": filepath.Join(home, "sessions", "rollout.jsonl")} switch scenario { case "missing-parent": diff --git a/apps/daemon/internal/agent/codex/rpc.go b/apps/daemon/internal/agent/codex/rpc.go index dc3bba9cb..2d2eceb56 100644 --- a/apps/daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -53,10 +53,12 @@ type JSONRPCConfig struct { // Cwd is the working directory for the child process. Empty // inherits the daemon's cwd. Cwd string - // Env is layered ON TOP of os.Environ() — set CODEX_HOME / OPENAI_API_KEY - // here. Empty values are not filtered (codex distinguishes empty - // from unset for some keys). + // Env is the child's complete environment. Empty values are not + // filtered (codex distinguishes empty from unset for some keys). Env []string + // Launch starts the child. nil runs clirunner.Start on this host; an + // agent-host view supplies ViewSession.Launch. + Launch func(clirunner.StartOptions) (*clirunner.Process, error) // LogTag is the prefix carried on every internal log line. LogTag string // RequestTimeout overrides rpcDefaultRequestTimeout. @@ -178,7 +180,11 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (Initi args = append(args, "--disable", f) } - process, err := clirunner.Start(clirunner.StartOptions{ + launch := c.cfg.Launch + if launch == nil { + launch = clirunner.Start + } + process, err := launch(clirunner.StartOptions{ Parent: ctx, Binary: c.cfg.Binary, Args: args, Dir: c.cfg.Cwd, Env: c.cfg.Env, NeedStdin: true, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, }) diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 9b8ce23b0..0e5798581 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -23,9 +23,12 @@ const terminalSendTimeout = 2 * time.Second // sessionConfig is the cross-cutting knob bag — production callers go // through Factory which uses defaults. type sessionConfig struct { + // codexBinary is the trusted install on this host. Probes run it here. codexBinary string logger *slog.Logger killTimeout time.Duration + // view runs codex in an agent-host Session view instead of on this host. + view *viewLaunch } func defaultSessionConfig() sessionConfig { @@ -64,7 +67,7 @@ type Session struct { operationMu sync.Mutex operations sync.WaitGroup operationsClosed bool - nativeHome string + nativeHome agent.ViewDir subagents *subagentObservations observeSubagentIdentities bool functions *functionCalls diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index 0fde0a6e0..2bf8a2cbb 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -37,7 +37,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.ApprovalPolicy = AskForApproval{String: "never"} } else if req.DisableExecutionEnvironment { // environment:none has no workspace; the Session's private home is its cwd. - plan.Cwd = nativeHomeFromPlan(plan) + plan.Cwd = plan.home.View } if req.LocalEnvironment != nil { diff --git a/apps/daemon/internal/agent/codex/subagent_observations_test.go b/apps/daemon/internal/agent/codex/subagent_observations_test.go index 6d10dd0a8..7c4b79415 100644 --- a/apps/daemon/internal/agent/codex/subagent_observations_test.go +++ b/apps/daemon/internal/agent/codex/subagent_observations_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -74,7 +75,7 @@ func observationSession(t *testing.T, status string) (*Session, *subagentFixture t.Fatal(err) } f.persist(t) - s := &Session{runID: "run", nativeHome: f.home, rpc: client.JSONRPCClient, out: out, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers(), interactions: newPendingCodexInteractions()} + s := &Session{runID: "run", nativeHome: agent.ViewDir{Host: f.home, View: f.home}, rpc: client.JSONRPCClient, out: out, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers(), interactions: newPendingCodexInteractions()} s.setThreadID("root") s.beginRootTurn("root", "root-turn") s.startSubagentObservations() diff --git a/apps/daemon/internal/agent/codex/subagent_rollout.go b/apps/daemon/internal/agent/codex/subagent_rollout.go index 20e063c2a..67e15dd49 100644 --- a/apps/daemon/internal/agent/codex/subagent_rollout.go +++ b/apps/daemon/internal/agent/codex/subagent_rollout.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -38,12 +39,19 @@ type rolloutCompletion struct { // The outer rollout timestamp is a write time. Only correlated native // ItemCompleted.completed_at_ms may timestamp a successful lifecycle effect. -func readSubagentEffects(home string, h *subagentHistory) ([]subagentEffect, error) { - rel, err := filepath.Rel(home, h.Path) - if !filepath.IsAbs(home) || !filepath.IsAbs(h.Path) || err != nil || !strings.HasPrefix(rel, "sessions"+string(filepath.Separator)) { +// h.Path is the path codex reports. The home opens from its parent, so a link +// left in place of the home cannot lead the read out of the parent. +func readSubagentEffects(home agent.ViewDir, h *subagentHistory) ([]subagentEffect, error) { + rel, err := filepath.Rel(home.View, h.Path) + if !filepath.IsAbs(home.View) || !filepath.IsAbs(home.Host) || !filepath.IsAbs(h.Path) || err != nil || !strings.HasPrefix(rel, "sessions"+string(filepath.Separator)) { return nil, errors.New("codex: subagent history escaped private native home") } - root, err := os.OpenRoot(home) + parent, err := os.OpenRoot(filepath.Dir(home.Host)) + if err != nil { + return nil, errors.New("codex: private subagent history unavailable") + } + defer parent.Close() + root, err := parent.OpenRoot(filepath.Base(home.Host)) if err != nil { return nil, errors.New("codex: private subagent history unavailable") } diff --git a/apps/daemon/internal/agent/codex/subagent_rollout_test.go b/apps/daemon/internal/agent/codex/subagent_rollout_test.go index 69489aeb3..37f53cf3d 100644 --- a/apps/daemon/internal/agent/codex/subagent_rollout_test.go +++ b/apps/daemon/internal/agent/codex/subagent_rollout_test.go @@ -5,9 +5,11 @@ import ( "os" "path/filepath" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) -func lifecycleRollout(t *testing.T, change func([]map[string]any)) (string, subagentHistory) { +func lifecycleRollout(t *testing.T, change func([]map[string]any)) (agent.ViewDir, subagentHistory) { t.Helper() home := t.TempDir() if err := os.Mkdir(filepath.Join(home, "sessions"), 0700); err != nil { @@ -31,7 +33,7 @@ func lifecycleRollout(t *testing.T, change func([]map[string]any)) (string, suba if err := os.WriteFile(h.Path, body, 0600); err != nil { t.Fatal(err) } - return home, h + return agent.ViewDir{Host: home, View: home}, h } func TestSubagentLifecycleRequiresCorrelatedNativeReceipt(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go new file mode 100644 index 000000000..a54d4de04 --- /dev/null +++ b/apps/daemon/internal/agent/codex/view.go @@ -0,0 +1,249 @@ +package codex + +import ( + "context" + "debug/elf" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "slices" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +const ( + // viewClosureName presents the install directory at ViewPrivateRoot/codex. + viewClosureName = "codex" + // codeModeHostName is the helper codex runs from beside its own binary. + codeModeHostName = "codex-code-mode-host" + // viewCodexHome and viewTempDir are CODEX_HOME and TMPDIR under the + // Session home, as siblings: codex refuses to create its helper aliases + // in a CODEX_HOME under TMPDIR. + viewCodexHome = "codex" + viewTempDir = "tmp" +) + +// viewForwardEnv lists what codex sets for the commands it runs: the unified +// exec defaults, its thread and session identity, and GIT_OPTIONAL_LOCKS for +// its own git calls. LANG is the view's. +var viewForwardEnv = []string{"NO_COLOR", "TERM", "LC_CTYPE", "LC_ALL", "COLORTERM", "PAGER", "GIT_PAGER", "GH_PAGER", "CODEX_CI", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "GIT_OPTIONAL_LOCKS"} + +// viewDisabledFeatures run local programs or load code the view does not +// declare: shell snapshots source rc files through the shim, hooks spawn a +// local shell, plugin sync and memories run git and npm against CODEX_HOME, +// and the skill MCP dependency install opens a browser. +var viewDisabledFeatures = []string{"shell_snapshot", "hooks", "plugins", "memories", "skill_mcp_dependency_install"} + +// viewLaunch is the agent-host view a Codex Executor runs in. +type viewLaunch struct { + agent.ViewSession + // binary is the LocalExec path of codex. + binary string +} + +// discoverView declares the view for the install discovery found. Only the +// pinned release is qualified, and only a static binary runs from its +// closure alone. +func discoverView(version string) *agent.View { + if !SupportsNativeSessionRecovery(version) { + return nil + } + binary, err := exec.LookPath(defaultBinary()) + if err == nil { + binary, err = filepath.Abs(binary) + } + if err == nil { + binary, err = filepath.EvalSymlinks(binary) + } + if err != nil || !staticELF(binary) { + return nil + } + view := newView(binary, staticELF(filepath.Join(filepath.Dir(binary), codeModeHostName))) + return &view +} + +// newView presents binary's directory as the closure. Commands codex runs +// through the passwd shell and git run in the sandbox; rg stays undeclared +// so codex falls back to its own search. +func newView(binary string, codeModeHost bool) agent.View { + mount := agent.ViewMount{Name: viewClosureName, HostDir: filepath.Dir(binary)} + launch := mount.Path() + "/" + filepath.Base(binary) + local := []string{launch} + if codeModeHost { + local = append(local, mount.Path()+"/"+codeModeHostName) + } + return agent.View{ + Closure: []agent.ViewMount{mount}, + Masks: []agent.ViewMask{{Path: "/etc/codex", Dir: true}}, + LocalExec: local, + Shims: []string{"git"}, + ShimPaths: []string{"/bin/bash"}, + ForwardEnv: slices.Clone(viewForwardEnv), + Proxy: agent.ViewProxyEnv, + Executor: func(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { + cfg := defaultSessionConfig() + cfg.codexBinary = binary + cfg.view = &viewLaunch{ViewSession: session, binary: launch} + executor, err := newExecutor(ctx, req, cfg) + if executor == nil { + return nil, err + } + return executor, err + }, + } +} + +// staticELF reports whether name is a regular executable ELF file that needs +// no interpreter and no shared library. +func staticELF(name string) bool { + info, err := os.Stat(name) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + return false + } + file, err := elf.Open(name) + if err != nil { + return false + } + defer file.Close() + for _, prog := range file.Progs { + if prog.Type == elf.PT_INTERP { + return false + } + } + libraries, err := file.ImportedLibraries() + return err == nil && len(libraries) == 0 +} + +// prepareViewPlan builds the plan for codex in the view: the Environment's +// workspace as cwd, CODEX_HOME and TMPDIR in the Session home, MCP only from +// the Session, and a closed environment. +func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, error) { + view := cfg.view + local := req.LocalEnvironment + if local == nil || req.DisableExecutionEnvironment || !path.IsAbs(local.WorkspaceRoot) { + return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace", agent.ErrUnsupportedOperation) + } + if local.Capabilities || len(local.Skills) > 0 || hasSkills(req.AgentOptions) { + return SessionPlan{}, fmt.Errorf("%w: codex: Capabilities and skills in a view", agent.ErrUnsupportedOperation) + } + if !filepath.IsAbs(view.Home.Host) || !path.IsAbs(view.Home.View) { + return SessionPlan{}, errors.New("codex: view home must be absolute") + } + if _, err := runtimePermissionProfile(req); err != nil { + return SessionPlan{}, err + } + for _, binding := range view.MCP { + if binding.Transport != "http" || binding.Stdio != nil { + return SessionPlan{}, fmt.Errorf("%w: codex: stdio MCP %q in a view", agent.ErrUnsupportedOperation, binding.ServerLabel) + } + } + servers, _, err := mcpServersFromBindings(view.MCP) + if err != nil { + return SessionPlan{}, err + } + plan, err := buildSessionPlan(req.AgentOptions, func() (agent.ViewDir, error) { return viewHome(view.Home) }) + if err != nil { + return SessionPlan{}, fmt.Errorf("codex: build session plan: %w", err) + } + if err := configureSubagentObservations(&plan, req); err != nil { + plan.Cleanup() + return SessionPlan{}, err + } + disableProgrammaticTools(&plan, req.ExecutionControls) + plan.Cwd = local.WorkspaceRoot + plan.Sandbox = SandboxDangerFullAcces + plan.Permissions = "" + plan.ApprovalPolicy = AskForApproval{String: "never"} + if req.DisableSubagents { + disableSubagents(&plan) + } + if err := configureMCP(&plan, servers); err != nil { + plan.Cleanup() + return SessionPlan{}, err + } + for _, feature := range viewDisabledFeatures { + plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) + if !slices.Contains(plan.DisableFeatures, feature) { + plan.DisableFeatures = append(plan.DisableFeatures, feature) + } + if override := [2]string{"features." + feature, "false"}; !slices.Contains(plan.ExtraConfig, override) { + plan.ExtraConfig = append(plan.ExtraConfig, override) + } + } + // No login shell, and no ancestor walk above the workspace over the + // mount. No trust entry is written, so the project stays untrusted. + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"allow_login_shell", "false"}, [2]string{"project_root_markers", "[]"}) + if stringOpt(req.AgentOptions, "model_verbosity") != "" { + if err := viewModelVerbosity(ctx, cfg.codexBinary, &plan, req.AgentOptions); err != nil { + plan.Cleanup() + return SessionPlan{}, err + } + } + env := []string{ + "HOME=" + view.Home.View, + "PATH=" + agent.ViewPrivateRoot + "/" + agent.ViewShimName, + "TMPDIR=" + path.Join(view.Home.View, viewTempDir), + } + for _, name := range []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} { + env = append(env, name+"="+view.Proxy) + } + plan.Env = append(append(env, "NO_PROXY=127.0.0.1,localhost", "no_proxy=127.0.0.1,localhost"), plan.Env...) + return plan, nil +} + +func hasSkills(opts map[string]any) bool { + raw := opts["skills"] + items, isList := raw.([]any) + return raw != nil && (!isList || len(items) > 0) +} + +// viewHome lays out CODEX_HOME and TMPDIR in the Session home. The Session +// user owns the home after a Launch, so neither may be a link. +func viewHome(home agent.ViewDir) (agent.ViewDir, error) { + root, err := os.OpenRoot(home.Host) + if err != nil { + return agent.ViewDir{}, fmt.Errorf("codex: open view home: %w", err) + } + defer root.Close() + for _, name := range []string{viewCodexHome, viewTempDir} { + if err := root.Mkdir(name, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { + return agent.ViewDir{}, fmt.Errorf("codex: create view home %s: %w", name, err) + } + if info, err := root.Lstat(name); err != nil || !info.IsDir() { + return agent.ViewDir{}, fmt.Errorf("codex: view home %s is not a directory", name) + } + } + return agent.ViewDir{Host: filepath.Join(home.Host, viewCodexHome), View: path.Join(home.View, viewCodexHome)}, nil +} + +// viewModelVerbosity reads the catalog from the trusted install on this host, +// with a scratch CODEX_HOME that holds only the Session's provider. +func viewModelVerbosity(ctx context.Context, binary string, plan *SessionPlan, opts map[string]any) error { + scratch, err := os.MkdirTemp("", "oac-codex-catalog-") + if err != nil { + return err + } + defer os.RemoveAll(scratch) + home, tmp := filepath.Join(scratch, "home"), filepath.Join(scratch, "tmp") + for _, dir := range []string{home, tmp} { + if err := os.Mkdir(dir, 0o700); err != nil { + return err + } + } + provider, hasProvider, err := normaliseProviderConfig(opts["model_provider"]) + if err != nil { + return err + } + if hasProvider { + if err := writeCodexProviderConfig(home, provider); err != nil { + return err + } + } + probe := catalogProbe{binary: binary, dir: home, env: []string{"HOME=" + home, "CODEX_HOME=" + home, "TMPDIR=" + tmp, "DISABLE_TELEMETRY=1"}} + return verifyModelVerbosity(ctx, probe, plan) +} diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go new file mode 100644 index 000000000..a28bfdc38 --- /dev/null +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -0,0 +1,97 @@ +package codex + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" +) + +func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { + const realKey = "sk-real-sentinel-key" + t.Setenv("OAC_VIEW_SENTINEL", "from-test-process") + t.Setenv("CODEX_API_KEY", realKey) + declared := newView(filepath.Join(t.TempDir(), "codex"), false) + info := Declaration.Info + info.Available = true + registry := agent.NewRegistry() + registry.Register(Declaration, agent.Runtime{Info: info, Session: Factory, View: &declared}) + view, err := registry.ResolveView("codex") + if err != nil { + t.Fatal(err) + } + + home := t.TempDir() + var launched []clirunner.StartOptions + session := agent.ViewSession{ + Home: agent.ViewDir{Host: home, View: agent.ViewPrivateRoot + "/" + agent.ViewHomeName}, + Proxy: "http://127.0.0.1:17100", + MCP: []agent.MCPBinding{{ServerLabel: "docs", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp"}}, + Launch: func(opts clirunner.StartOptions) (*clirunner.Process, error) { + launched = append(launched, opts) + return nil, errors.New("recorded") + }, + } + req := proto.PromptRequestPayload{ + AgentStateKey: "state", + AgentOptions: map[string]any{"model": "m", "model_provider": map[string]any{"protocol": "responses", "base_url": "http://127.0.0.1:17101", "api_key": modelprovider.Placeholder}}, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceRoot: "/workspace", NetworkAccess: "enabled"}, + } + if _, err := view.Executor(t.Context(), req, session); err == nil || len(launched) != 1 { + t.Fatalf("launches %d, err %v", len(launched), err) + } + launch := launched[0] + if !slices.Contains(view.LocalExec, launch.Binary) { + t.Fatalf("binary %q is not in LocalExec %v", launch.Binary, view.LocalExec) + } + for _, entry := range launch.Env { + if strings.HasPrefix(entry, "OAC_VIEW_SENTINEL=") { + t.Fatal("the test process environment reached the Harness") + } + } + config, err := os.ReadFile(filepath.Join(home, viewCodexHome, "config.toml")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`base_url = "http://127.0.0.1:17101"` + "\n", `experimental_bearer_token = "` + modelprovider.Placeholder + `"`, `"http://127.0.0.1:17102/mcp"`} { + if !strings.Contains(string(config), want) { + t.Fatalf("config.toml lacks %s:\n%s", want, config) + } + } + exposed := append(append([]string{launch.Binary}, launch.Args...), launch.Env...) + err = filepath.WalkDir(home, func(name string, entry fs.DirEntry, err error) error { + if err == nil && entry.Type().IsRegular() { + body, readErr := os.ReadFile(name) + exposed = append(exposed, string(body)) + err = readErr + } + return err + }) + if err != nil { + t.Fatal(err) + } + for _, text := range exposed { + if strings.Contains(text, realKey) { + t.Fatal("the real key reached the view") + } + } + args := strings.Join(launch.Args, " ") + for _, override := range []string{"features.shell_snapshot=false", "allow_login_shell=false", "features.hooks=false", "features.plugins=false", "features.memories=false", "features.skill_mcp_dependency_install=false", "project_root_markers=[]"} { + if !strings.Contains(args, "-c "+override) { + t.Fatalf("missing -c %s in %s", override, args) + } + } + + session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{}}} + if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) || len(launched) != 1 { + t.Fatalf("stdio MCP: launches %d, err %v", len(launched), err) + } +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index ebc926eff..1942f5169 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -108,6 +108,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Native Item variants beyond those listed under [Turns and Items](sessions-events.md#turns-and-items) are not projected, and Items cannot be modified. - A function result that cancellation prevents from being applied never appears as an Item. - Pinned Codex can lose command output emitted before its stream subscription. +- Behind the [credential gateway](model-execution.md#credential-gateway), pinned Codex compacts history locally and never calls `/responses/compact`. - Claude Code and MiniMax Code report no public usage. - Core gives no crash-safe or exactly-once guarantee for native side effects; claimed work fails on restart without replay. - Images must be inline PNG or JPEG data URIs; remote URLs, `file_id` and `detail` are rejected. From ec9074e7989305eb6cc76303b09899a642a8a0c6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 02:52:33 +0000 Subject: [PATCH 2/2] Confine Codex native home access to an os.Root The Session user owns a view home and can leave links in it between turns, while the daemon writes config.toml, the MCP section and the model catalog there as root. Every read, write, create and remove in CODEX_HOME now goes through a Root opened from the home's parent, so a link resolves only inside the home. The model catalog is created with O_EXCL inside that Root. --- .../daemon/internal/agent/codex/mcp_config.go | 8 +---- apps/daemon/internal/agent/codex/mcp_http.go | 8 +++-- .../internal/agent/codex/model_verbosity.go | 24 +++++++++----- apps/daemon/internal/agent/codex/options.go | 31 +++++++++++++++++-- .../internal/agent/codex/provider_config.go | 23 +++++++------- .../internal/agent/codex/subagent_rollout.go | 8 +---- apps/daemon/internal/agent/codex/view_test.go | 30 +++++++++++++++++- 7 files changed, 93 insertions(+), 39 deletions(-) diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index aac6309cb..e1d1278e9 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -2,8 +2,6 @@ package codex import ( "fmt" - "os" - "path/filepath" "sort" "strings" ) @@ -35,9 +33,6 @@ type mcpServerConfig struct { // once per prompt after resetGeneratedConfig; native history stays in CODEX_HOME. // The transport and enabled_tools fields mirror native McpServerConfig. func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) error { - if err := os.MkdirAll(codexHome, 0o700); err != nil { - return fmt.Errorf("codex: mkdir CODEX_HOME %s: %w", codexHome, err) - } names := make([]string, 0, len(servers)) for name := range servers { names = append(names, name) @@ -113,8 +108,7 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteByte('\n') } - path := filepath.Join(codexHome, "config.toml") - return appendConfigTOML(path, b.String()) + return appendConfigTOML(codexHome, b.String()) } func writeMCPHeaderMap(b *strings.Builder, field string, values map[string]string) { diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index d74292b7b..c86df681f 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "errors" "os" - "path/filepath" "slices" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -57,12 +56,15 @@ func mcpServersFromBindings(bindings []agent.MCPBinding) (map[string]mcpServerCo func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { codexHome := plan.home.Host - if !filepath.IsAbs(codexHome) { + root, err := openNativeHome(codexHome) + if err != nil { return errors.New("codex: public MCP requires a private native home") } // Native OAuth defaults to the global keyring. File mode confines lookup to // this owned history directory; never delete existing credentials to admit it. - if _, err := os.Lstat(filepath.Join(codexHome, ".credentials.json")); !errors.Is(err, os.ErrNotExist) { + _, err = root.Lstat(".credentials.json") + root.Close() + if !errors.Is(err, os.ErrNotExist) { return errors.New("codex: public MCP requires a native home without stored MCP credentials") } if err := writeCodexMCPConfig(codexHome, servers); err != nil { diff --git a/apps/daemon/internal/agent/codex/model_verbosity.go b/apps/daemon/internal/agent/codex/model_verbosity.go index 0916691cc..79027522a 100644 --- a/apps/daemon/internal/agent/codex/model_verbosity.go +++ b/apps/daemon/internal/agent/codex/model_verbosity.go @@ -2,10 +2,11 @@ package codex import ( "context" + "crypto/rand" "encoding/json" "fmt" "os" - "path/filepath" + "path" "slices" "strings" ) @@ -57,26 +58,35 @@ func verifyModelVerbosity(ctx context.Context, probe catalogProbe, plan *Session plan.ExtraConfig = slices.DeleteFunc(plan.ExtraConfig, func(kv [2]string) bool { return kv[0] == "model_verbosity" }) } codexHome := plan.home.Host - if !filepath.IsAbs(codexHome) { - return fmt.Errorf("codex: missing managed home for model catalog") + root, err := openNativeHome(codexHome) + if err != nil { + return fmt.Errorf("codex: missing managed home for model catalog: %w", err) } - file, err := os.CreateTemp(codexHome, "model-catalog-*.json") + defer root.Close() + name := "model-catalog-" + rand.Text() + ".json" + file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err } _, writeErr := file.Write(catalog) closeErr := file.Close() if writeErr != nil || closeErr != nil { - _ = os.Remove(file.Name()) + _ = root.Remove(name) if writeErr != nil { return writeErr } return closeErr } cleanup := plan.Cleanup - plan.Cleanup = func() { _ = os.Remove(file.Name()); cleanup() } + plan.Cleanup = func() { + if root, err := openNativeHome(codexHome); err == nil { + _ = root.Remove(name) + root.Close() + } + cleanup() + } // Codex reads the catalog at its own path for the same file. - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(plan.home.View + strings.TrimPrefix(file.Name(), codexHome))}) + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(path.Join(plan.home.View, name))}) return nil } diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 96c9b1672..5466af90d 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -248,10 +248,35 @@ func allocCodexHome(agentStateKey string) (string, error) { return dir, nil } +// openNativeHome opens CODEX_HOME from its parent. Every read and write in the +// home goes through this Root: the Session user owns a view home, and a link +// it leaves there resolves only inside the parent, never outside it. +func openNativeHome(codexHome string) (*os.Root, error) { + if !filepath.IsAbs(codexHome) { + return nil, errors.New("codex: missing private native home") + } + parent, err := os.OpenRoot(filepath.Dir(codexHome)) + if err != nil { + return nil, fmt.Errorf("codex: open native home: %w", err) + } + defer parent.Close() + root, err := parent.OpenRoot(filepath.Base(codexHome)) + if err != nil { + return nil, fmt.Errorf("codex: open native home: %w", err) + } + return root, nil +} + +// resetGeneratedConfig removes config.toml; a link in its place is removed, +// never followed. func resetGeneratedConfig(codexHome string) error { - path := filepath.Join(codexHome, "config.toml") - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("codex: remove generated config %s: %w", path, err) + root, err := openNativeHome(codexHome) + if err != nil { + return err + } + defer root.Close() + if err := root.Remove("config.toml"); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("codex: remove generated config: %w", err) } return nil } diff --git a/apps/daemon/internal/agent/codex/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go index b3c571e0d..4c7f0f6f8 100644 --- a/apps/daemon/internal/agent/codex/provider_config.go +++ b/apps/daemon/internal/agent/codex/provider_config.go @@ -3,7 +3,6 @@ package codex import ( "fmt" "os" - "path/filepath" "sort" "strings" ) @@ -60,9 +59,6 @@ type providerConfig struct { // The provider block is rewritten on every prompt; manual edits to // scratch CODEX_HOME files are lost on the next spawn. func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { - if err := os.MkdirAll(codexHome, 0o700); err != nil { - return fmt.Errorf("codex: mkdir CODEX_HOME %s: %w", codexHome, err) - } if strings.TrimSpace(cfg.BaseURL) == "" { return fmt.Errorf("codex: provider base_url is required") } @@ -148,8 +144,7 @@ func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { b.WriteByte('\n') - path := filepath.Join(codexHome, "config.toml") - return appendConfigTOML(path, b.String()) + return appendConfigTOML(codexHome, b.String()) } // appendConfigTOML appends body to /config.toml, creating it @@ -159,15 +154,21 @@ func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { // // File is opened O_APPEND so concurrent writers in the same prompt // (today: at most one of each) don't race. 0o600 perms because the -// file carries the API bearer token in plaintext. -func appendConfigTOML(path string, body string) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) +// file carries the API bearer token in plaintext. The file opens inside the +// native home Root, so a link at config.toml cannot lead the write outside. +func appendConfigTOML(codexHome string, body string) error { + root, err := openNativeHome(codexHome) + if err != nil { + return err + } + defer root.Close() + f, err := root.OpenFile("config.toml", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { - return fmt.Errorf("codex: open %s: %w", path, err) + return fmt.Errorf("codex: open config.toml: %w", err) } defer f.Close() if _, err := f.WriteString(body); err != nil { - return fmt.Errorf("codex: append %s: %w", path, err) + return fmt.Errorf("codex: append config.toml: %w", err) } return nil } diff --git a/apps/daemon/internal/agent/codex/subagent_rollout.go b/apps/daemon/internal/agent/codex/subagent_rollout.go index 67e15dd49..a0fa02882 100644 --- a/apps/daemon/internal/agent/codex/subagent_rollout.go +++ b/apps/daemon/internal/agent/codex/subagent_rollout.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "io" - "os" "path/filepath" "strings" @@ -46,12 +45,7 @@ func readSubagentEffects(home agent.ViewDir, h *subagentHistory) ([]subagentEffe if !filepath.IsAbs(home.View) || !filepath.IsAbs(home.Host) || !filepath.IsAbs(h.Path) || err != nil || !strings.HasPrefix(rel, "sessions"+string(filepath.Separator)) { return nil, errors.New("codex: subagent history escaped private native home") } - parent, err := os.OpenRoot(filepath.Dir(home.Host)) - if err != nil { - return nil, errors.New("codex: private subagent history unavailable") - } - defer parent.Close() - root, err := parent.OpenRoot(filepath.Base(home.Host)) + root, err := openNativeHome(home.Host) if err != nil { return nil, errors.New("codex: private subagent history unavailable") } diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go index a28bfdc38..489c1c6ea 100644 --- a/apps/daemon/internal/agent/codex/view_test.go +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -29,7 +29,19 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { t.Fatal(err) } + // A Harness from an earlier turn can leave links in the home it owns. home := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("outside\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(home, viewCodexHome), 0o700); err != nil { + t.Fatal(err) + } + planted := filepath.Join(home, viewCodexHome, "config.toml") + if err := os.Symlink(outside, planted); err != nil { + t.Fatal(err) + } var launched []clirunner.StartOptions session := agent.ViewSession{ Home: agent.ViewDir{Host: home, View: agent.ViewPrivateRoot + "/" + agent.ViewHomeName}, @@ -57,7 +69,10 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { t.Fatal("the test process environment reached the Harness") } } - config, err := os.ReadFile(filepath.Join(home, viewCodexHome, "config.toml")) + if info, err := os.Lstat(planted); err != nil || !info.Mode().IsRegular() { + t.Fatalf("config.toml was not replaced: %v", err) + } + config, err := os.ReadFile(planted) if err != nil { t.Fatal(err) } @@ -90,6 +105,19 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { } } + if err := os.Remove(planted); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, planted); err != nil { + t.Fatal(err) + } + if err := appendConfigTOML(filepath.Join(home, viewCodexHome), "x = 1\n"); err == nil { + t.Fatal("a write followed a link out of the home") + } + if body, err := os.ReadFile(outside); err != nil || string(body) != "outside\n" { + t.Fatalf("outside file changed: %q, %v", body, err) + } + session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{}}} if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) || len(launched) != 1 { t.Fatalf("stdio MCP: launches %d, err %v", len(launched), err)