diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 7020b66c..d38c5338 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 652c5cc1..298554b0 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_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index aac6309c..e1d1278e 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 bdeb2af1..c86df681 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" @@ -22,6 +21,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,13 +55,16 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf } func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { - codexHome := nativeHomeFromPlan(*plan) - if !filepath.IsAbs(codexHome) { + codexHome := plan.home.Host + 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 6c9d4f28..79027522 100644 --- a/apps/daemon/internal/agent/codex/model_verbosity.go +++ b/apps/daemon/internal/agent/codex/model_verbosity.go @@ -2,17 +2,29 @@ package codex import ( "context" + "crypto/rand" "encoding/json" "fmt" "os" - "path/filepath" + "path" "slices" "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 +32,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,26 +57,36 @@ 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) - if !filepath.IsAbs(codexHome) { - return fmt.Errorf("codex: missing managed home for model catalog") + codexHome := plan.home.Host + 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.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(file.Name())}) + 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(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 54defb0c..5466af90 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,22 +248,35 @@ 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 - } +// 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 home + 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/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 44d750c0..1998f252 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/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go index b3c571e0..4c7f0f6f 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/recovery.go b/apps/daemon/internal/agent/codex/recovery.go index 1f787cf2..79bd4a76 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 da32ad87..0fba6976 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 dc3bba9c..2d2eceb5 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 9b8ce23b..0e579858 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 0fde0a6e..2bf8a2cb 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 6d10dd0a..7c4b7941 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 20e063c2..a0fa0288 100644 --- a/apps/daemon/internal/agent/codex/subagent_rollout.go +++ b/apps/daemon/internal/agent/codex/subagent_rollout.go @@ -5,10 +5,10 @@ import ( "encoding/json" "errors" "io" - "os" "path/filepath" "strings" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -38,12 +38,14 @@ 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) + 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/subagent_rollout_test.go b/apps/daemon/internal/agent/codex/subagent_rollout_test.go index 69489aeb..37f53cf3 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 00000000..a54d4de0 --- /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 00000000..489c1c6e --- /dev/null +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -0,0 +1,125 @@ +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) + } + + // 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}, + 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") + } + } + 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) + } + 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) + } + } + + 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) + } +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index ebc926ef..1942f516 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.