diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72bcd1f5b..d798c1976 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,9 +124,9 @@ jobs: CORE_DISTRIBUTION_OFFLINE: ${{ (github.event_name == 'push' || inputs.offline) && '1' || '0' }} run: | inputs="$HOME/.oac/build/release-inputs/inputs.json" - AGENTS_RUNTIME_CODEX_PACKAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" + CODEX_CLI_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" MCODE_HARNESS_BUILD_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["mcode"])' "$inputs")" - export AGENTS_RUNTIME_CODEX_PACKAGE MCODE_HARNESS_BUILD_DIR + export CODEX_CLI_DIR MCODE_HARNESS_BUILD_DIR export CORE_DISTRIBUTION_RELEASE_BASE_URL="https://github.com/$RELEASE_REPOSITORY/releases/download/$RELEASE_TAG" bash scripts/build-core-distribution.sh mkdir -p "$HOME/.oac/build/release-upload" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04cbec682..95a989fe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,7 +142,7 @@ Native adapter changes require their build/check targets and live provider accep | Provider ownership labels | `io.oac.*` | | E2B metadata | `oac_*` | -Provider bootstrap, Runtime images and Harness adapters must agree on these names. The separate Parsar product integration settings keep their own names. +Provider bootstrap, Runtime images and Harness adapters must agree on these names. The [installation version policy](docs/getting-started/operations.md#installation-version-policy) owns release changes and preservation of installed data and resources. diff --git a/Makefile b/Makefile index a69748107..f755dec39 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-codex-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime help: @printf '%s\n' 'make build-core Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make build-sandbox-io Build the Sandbox I/O service for Linux' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' @@ -151,8 +151,8 @@ check-mcode-harness: @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh -build-agents-runtime: - ./scripts/build-agents-runtime.sh +build-codex-runtime: + ./scripts/build-codex-runtime.sh build-claude-runtime: ./scripts/build-claude-runtime.sh diff --git a/README.md b/README.md index 6277909fd..5713ac292 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source, self-hosted implementation of the OpenAI Agents API with multiple native harnesses. -[Website](https://minimax-ai.github.io/OpenAgentCore/) · [Install](#install) · [Call the API](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/quickstart) · [Documentation](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/) · [Contributing](CONTRIBUTING.md) +[Website](https://openagentcore.dev/) · [Install](#install) · [Call the API](https://openagentcore.dev/docs/getting-started/quickstart) · [Documentation](https://openagentcore.dev/docs/getting-started/) · [Contributing](CONTRIBUTING.md) **English** · [简体中文](README.zh-CN.md) @@ -17,9 +17,9 @@ An open-source, self-hosted implementation of the OpenAI Agents API with multipl OpenAgentCore runs AI agents on your own infrastructure behind the [OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview). - **Same API as OpenAI.** Point the [OpenAI Agent API](https://developers.openai.com/api/docs/guides/agents/sdk), or plain HTTP, at your installation. No new client to learn. -- **Your choice of agent.** Each [Session](https://minimax-ai.github.io/OpenAgentCore/docs/api/public-agent-api) runs a [native harness](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/harness-onboarding): [Codex](https://github.com/openai/codex), [Claude Code](https://code.claude.com/docs/en/overview) or [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), with the [model provider you configure](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/model-execution). -- **Your choice of machine.** Agents work in a [managed sandbox](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/sandbox-deployment) ([Docker](https://www.docker.com/), [microsandbox](https://github.com/zerocore-ai/microsandbox) or [E2B](https://e2b.dev/)), or on your own Linux, macOS or Windows machine. -- **Every part is replaceable.** [Sandboxes](https://minimax-ai.github.io/OpenAgentCore/docs/sandbox-provider), [harnesses](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/harness-onboarding) and [model providers](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/model-execution) plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary). +- **Your choice of agent.** Each [Session](https://openagentcore.dev/docs/api/public-agent-api) runs a [native harness](https://openagentcore.dev/contracts/agents-api/harness-onboarding): [Codex](https://github.com/openai/codex), [Claude Code](https://code.claude.com/docs/en/overview) or [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), with the [model provider you configure](https://openagentcore.dev/contracts/agents-api/model-execution). +- **Your choice of machine.** Agents work in a [managed sandbox](https://openagentcore.dev/contracts/agents-api/sandbox-deployment) ([Docker](https://www.docker.com/), [microsandbox](https://github.com/zerocore-ai/microsandbox) or [E2B](https://e2b.dev/)), or on your own Linux, macOS or Windows machine. +- **Every part is replaceable.** [Sandboxes](https://openagentcore.dev/docs/sandbox-provider), [harnesses](https://openagentcore.dev/contracts/agents-api/harness-onboarding) and [model providers](https://openagentcore.dev/contracts/agents-api/model-execution) plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary). ## How it fits together @@ -29,10 +29,10 @@ Applications and operators use these Core APIs: | API | Path | Used by | | --- | --- | --- | -| **[Agents API](https://minimax-ai.github.io/OpenAgentCore/docs/api/public-agent-api)** | `/v1` | Your applications. Same protocol as [OpenAI's Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) | -| **[Core API](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/admin-api)** | `/core/v1` | Operators, through Web | +| **[Agents API](https://openagentcore.dev/docs/api/public-agent-api)** | `/v1` | Your applications. Same protocol as [OpenAI's Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) | +| **[Core API](https://openagentcore.dev/contracts/agents-api/admin-api)** | `/core/v1` | Operators, through Web | -Core keeps durable execution state. The Runtime runs the chosen harness inside the Environment. Each connection is a defined protocol, so any part can be replaced on its own. See the [architecture guide](https://minimax-ai.github.io/OpenAgentCore/docs/architecture). +Core keeps durable execution state. The Runtime runs the chosen harness inside the Environment. Each connection is a defined protocol, so any part can be replaced on its own. See the [architecture guide](https://openagentcore.dev/docs/architecture). ## Screenshots @@ -53,20 +53,20 @@ Then: 1. **Sign in to Web**, the admin console, with the Core key the installer created, and **configure the domain and HTTPS**. 2. **Set a default model** and **issue a Project API key**. 3. **Add execution capacity:** a node, E2B, or your own machine. -4. **[Run your first Session](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/quickstart)** with the OpenAI SDK. +4. **[Run your first Session](https://openagentcore.dev/docs/getting-started/quickstart)** with the OpenAI SDK. -The [installation guide](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/install) covers each step, HTTPS and a quick local trial. Listen addresses, ports and other options: [installation options](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/install-options). +The [installation guide](https://openagentcore.dev/docs/getting-started/install) covers each step, HTTPS and a quick local trial. Listen addresses, ports and other options: [installation options](https://openagentcore.dev/docs/getting-started/install-options). ## Documentation | I want to | Start with | | --- | --- | -| Install and operate an installation | [Installation](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/install), then [operations](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/operations) | -| Build an application on the API | [Quickstart](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/quickstart), then the [Agents API guide](https://minimax-ai.github.io/OpenAgentCore/docs/api/public-agent-api) | -| See a complete application | [Examples](https://minimax-ai.github.io/OpenAgentCore/docs/examples) | -| Run agents on my own machine | [Self-hosted execution](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/self-hosted) | -| Check Harness capabilities and limits | [Harness capabilities](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/harness-capabilities) | -| Understand the design | [Architecture](https://minimax-ai.github.io/OpenAgentCore/docs/architecture) | -| Add a sandbox, harness or other component | [Developer guide](https://minimax-ai.github.io/OpenAgentCore/docs/development) | - -All pages: [documentation index](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/). Before changing code, read the [contributor rules](CONTRIBUTING.md). +| Install and operate an installation | [Installation](https://openagentcore.dev/docs/getting-started/install), then [operations](https://openagentcore.dev/docs/getting-started/operations) | +| Build an application on the API | [Quickstart](https://openagentcore.dev/docs/getting-started/quickstart), then the [Agents API guide](https://openagentcore.dev/docs/api/public-agent-api) | +| See a complete application | [Examples](https://openagentcore.dev/docs/examples) | +| Run agents on my own machine | [Self-hosted execution](https://openagentcore.dev/docs/getting-started/self-hosted) | +| Check Harness capabilities and limits | [Harness capabilities](https://openagentcore.dev/contracts/agents-api/harness-capabilities) | +| Understand the design | [Architecture](https://openagentcore.dev/docs/architecture) | +| Add a sandbox, harness or other component | [Developer guide](https://openagentcore.dev/docs/development) | + +All pages: [documentation index](https://openagentcore.dev/docs/getting-started/). Before changing code, read the [contributor rules](CONTRIBUTING.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index b2d5f42a9..5414cea3c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ OpenAI Agents API 的开源实现,支持多种原生执行引擎,可部署在自己的基础设施上。 -[官网](https://minimax-ai.github.io/OpenAgentCore/zh/) · [安装](#安装) · [调用 API](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/quickstart) · [文档](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/) · [参与贡献](CONTRIBUTING.md) +[官网](https://openagentcore.dev/zh/) · [安装](#安装) · [调用 API](https://openagentcore.dev/zh/docs/getting-started/quickstart) · [文档](https://openagentcore.dev/zh/docs/getting-started/) · [参与贡献](CONTRIBUTING.md) [English](README.md) · **简体中文** @@ -20,19 +20,19 @@ OpenAI Agents API 的开源实现,支持多种原生执行引擎,可部署 | API | 路径 | 调用方 | | --- | --- | --- | -| **[Agents API](https://minimax-ai.github.io/OpenAgentCore/zh/docs/api/public-agent-api)** | `/v1` | 你的应用,与 [OpenAI 的 Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) 协议一致 | -| **[Core API](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/admin-api)** | `/core/v1` | 管理员,通过 Web 调用 | +| **[Agents API](https://openagentcore.dev/zh/docs/api/public-agent-api)** | `/v1` | 你的应用,与 [OpenAI 的 Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) 协议一致 | +| **[Core API](https://openagentcore.dev/zh/contracts/agents-api/admin-api)** | `/core/v1` | 管理员,通过 Web 调用 | -持久化执行状态由 Core 保存;Runtime 在 Environment 中运行所选 Harness。各部件之间都通过既定协议连接, 任何一个都可以单独替换。详见[架构说明](https://minimax-ai.github.io/OpenAgentCore/zh/docs/architecture)。 +持久化执行状态由 Core 保存;Runtime 在 Environment 中运行所选 Harness。各部件之间都通过既定协议连接, 任何一个都可以单独替换。详见[架构说明](https://openagentcore.dev/zh/docs/architecture)。 ## 这是什么 OpenAgentCore 在你自己的基础设施上运行 AI Agent,对外提供 [OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview)。 - **与 OpenAI 相同的 API。** [官方 OpenAI SDK](https://developers.openai.com/api/docs/guides/agents/sdk) 或直接 HTTP 调用,改一下地址即可,无需学习新客户端。 -- **自选 Agent。** 每个 [Session](https://minimax-ai.github.io/OpenAgentCore/zh/docs/api/public-agent-api) 运行一个[原生 Harness](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/harness-onboarding):[Codex](https://github.com/openai/codex)、[Claude Code](https://code.claude.com/docs/en/overview) 或 [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), 使用[你配置的模型供应商](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/model-execution)。 -- **自选机器。** Agent 可以在[托管沙箱](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/sandbox-deployment)([Docker](https://www.docker.com/)、[microsandbox](https://github.com/zerocore-ai/microsandbox) 或 [E2B](https://e2b.dev/))里工作, 也可以在你自己的 Linux、macOS 或 Windows 机器上工作。 -- **每个部件都可替换。** [沙箱](https://minimax-ai.github.io/OpenAgentCore/zh/docs/sandbox-provider)、[Harness](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/harness-onboarding) 和[模型供应商](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/model-execution)都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。 +- **自选 Agent。** 每个 [Session](https://openagentcore.dev/zh/docs/api/public-agent-api) 运行一个[原生 Harness](https://openagentcore.dev/zh/contracts/agents-api/harness-onboarding):[Codex](https://github.com/openai/codex)、[Claude Code](https://code.claude.com/docs/en/overview) 或 [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), 使用[你配置的模型供应商](https://openagentcore.dev/zh/contracts/agents-api/model-execution)。 +- **自选机器。** Agent 可以在[托管沙箱](https://openagentcore.dev/zh/contracts/agents-api/sandbox-deployment)([Docker](https://www.docker.com/)、[microsandbox](https://github.com/zerocore-ai/microsandbox) 或 [E2B](https://e2b.dev/))里工作, 也可以在你自己的 Linux、macOS 或 Windows 机器上工作。 +- **每个部件都可替换。** [沙箱](https://openagentcore.dev/zh/docs/sandbox-provider)、[Harness](https://openagentcore.dev/zh/contracts/agents-api/harness-onboarding) 和[模型供应商](https://openagentcore.dev/zh/contracts/agents-api/model-execution)都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。 ## 界面预览 @@ -53,20 +53,20 @@ curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/ 1. 用安装器生成的 Core key **登录 Web**(管理控制台),并**配置域名和 HTTPS**。 2. **设置默认模型**,并**签发 Project API key**。 3. **添加执行资源**:节点、E2B,或你自己的机器。 -4. 用 OpenAI SDK **[运行第一个 Session](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/quickstart)**。 +4. 用 OpenAI SDK **[运行第一个 Session](https://openagentcore.dev/zh/docs/getting-started/quickstart)**。 -[安装指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/install)详细介绍每一步,以及 HTTPS 配置和本地快速试用。监听地址、端口等参数见[安装配置选项](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/install-options)。 +[安装指南](https://openagentcore.dev/zh/docs/getting-started/install)详细介绍每一步,以及 HTTPS 配置和本地快速试用。监听地址、端口等参数见[安装配置选项](https://openagentcore.dev/zh/docs/getting-started/install-options)。 ## 文档 | 我想要 | 从这里开始 | | --- | --- | -| 安装并运维 | [安装指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/install),然后看[运维](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/operations) | -| 基于 API 开发应用 | [快速开始](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/quickstart),然后看 [Agents API 指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/api/public-agent-api) | -| 看一个完整的应用 | [示例](https://minimax-ai.github.io/OpenAgentCore/zh/docs/examples) | -| 在自己的机器上运行 Agent | [自托管执行](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/self-hosted) | -| 查看 Harness 能力和限制 | [Harness 能力](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/harness-capabilities) | -| 了解设计 | [架构说明](https://minimax-ai.github.io/OpenAgentCore/zh/docs/architecture) | -| 接入新的沙箱、Harness 或其他组件 | [开发指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/development) | - -全部文档见[文档目录](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/)。修改代码前请阅读[贡献规范](CONTRIBUTING.md)。 +| 安装并运维 | [安装指南](https://openagentcore.dev/zh/docs/getting-started/install),然后看[运维](https://openagentcore.dev/zh/docs/getting-started/operations) | +| 基于 API 开发应用 | [快速开始](https://openagentcore.dev/zh/docs/getting-started/quickstart),然后看 [Agents API 指南](https://openagentcore.dev/zh/docs/api/public-agent-api) | +| 看一个完整的应用 | [示例](https://openagentcore.dev/zh/docs/examples) | +| 在自己的机器上运行 Agent | [自托管执行](https://openagentcore.dev/zh/docs/getting-started/self-hosted) | +| 查看 Harness 能力和限制 | [Harness 能力](https://openagentcore.dev/zh/contracts/agents-api/harness-capabilities) | +| 了解设计 | [架构说明](https://openagentcore.dev/zh/docs/architecture) | +| 接入新的沙箱、Harness 或其他组件 | [开发指南](https://openagentcore.dev/zh/docs/development) | + +全部文档见[文档目录](https://openagentcore.dev/zh/docs/getting-started/)。修改代码前请阅读[贡献规范](CONTRIBUTING.md)。 diff --git a/apps/daemon/cmd/oac-daemon/main.go b/apps/daemon/cmd/oac-daemon/main.go index b704d8cc3..7ed9bf050 100644 --- a/apps/daemon/cmd/oac-daemon/main.go +++ b/apps/daemon/cmd/oac-daemon/main.go @@ -1,5 +1,5 @@ -// Command oac-daemon is the reverse-WebSocket worker that pairs a user -// machine with a OpenAgentCore server and exposes a local agent CLI +// Command oac-daemon is the reverse-WebSocket worker that connects a +// machine to an OpenAgentCore server and exposes a local agent CLI // subprocess as a connector_type=agent_daemon target. See // apps/daemon/README.md for the subcommand spec. package main diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go index 2e042f292..86f7e0cf0 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -66,7 +66,7 @@ func TestLiveClaudeSDKCancelResume(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} running, err := NewFactory(config)(ctx, request, out) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/commands.go b/apps/daemon/internal/agent/claudesdk/commands.go index 46dfa31b1..9bc4c266a 100644 --- a/apps/daemon/internal/agent/claudesdk/commands.go +++ b/apps/daemon/internal/agent/claudesdk/commands.go @@ -33,9 +33,7 @@ func (c *commandState) receive(event bridgeEvent, start startRequest, sessionID return fmt.Errorf("claudesdk: inconsistent command observation") } c.calls[event.ID] = *n - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "Bash", Stage: event.Stage, Observation: n}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "Bash", Stage: event.Stage, Observation: n}) return nil } @@ -48,15 +46,13 @@ func (c *commandState) complete() bool { return true } -func (c *commandState) close(start startRequest, emit func(string, any)) { +func (c *commandState) close(emit func(string, any)) { for id, call := range c.calls { if call.Status != "in_progress" { continue } call.Status = "incomplete" c.calls[id] = call - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "Bash", Stage: "after", Observation: &call}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "Bash", Stage: "after", Observation: &call}) } } diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index 12becd34a..8ed414dc4 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -16,74 +16,56 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestWorkspaceCommandsRequirePackagedFeatureOnlyWhenRequested(t *testing.T) { - for _, observed := range []bool{false, true} { - config := preparationFixture(t, "old-command-runtime") - req := preparationRequest() - req.ObserveToolObservations = observed - resource, err := NewExecutorFactory(config)(t.Context(), req) - if observed { - if err == nil || !strings.Contains(err.Error(), "workspace command observations") { - t.Fatal("old bridge accepted requested command observations", err) - } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { - t.Fatal("old bridge started execution before rejection") - } - } else { - if err != nil { - t.Fatal("old bridge changed opt-out behavior", err) - } - if err := resource.Close(t.Context()); err != nil { - t.Fatal(err) - } - } +func TestWorkspaceCommandsRequirePackagedFeature(t *testing.T) { + config := preparationFixture(t, "old-command-runtime") + if _, err := NewExecutorFactory(config)(t.Context(), preparationRequest()); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { + t.Fatal("old bridge accepted command observations", err) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + t.Fatal("old bridge started execution before rejection") } } func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) { - for _, observed := range []bool{false, true} { - config := preparationFixture(t, "commands-success") - req := preparationRequest() - req.ObserveToolObservations = observed - resource, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer resource.Close(context.Background()) - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { - t.Fatal("preparation submitted a command") - } - out := make(chan proto.Envelope, 16) - s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) + config := preparationFixture(t, "commands-success") + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + defer resource.Close(context.Background()) + if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + t.Fatal("preparation submitted a command") + } + out := make(chan proto.Envelope, 16) + s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + var frames []proto.ToolCallPayload + done := 0 + for event := range out { + if event.ID != "actual-command-run" || event.Type == proto.TypeError || event.Type == proto.TypeCommandOutput { + t.Fatal("execution identity or final-only command behavior changed", event.Type) } - defer s.Cancel(context.Background()) - var frames []proto.ToolCallPayload - done := 0 - for event := range out { - if event.ID != "actual-command-run" || event.Type == proto.TypeError || event.Type == proto.TypeCommandOutput { - t.Fatal("execution identity or final-only command behavior changed", event.Type) - } - if event.Type == proto.TypeToolCall { - var payload proto.ToolCallPayload - if err := event.DecodePayload(&payload); err != nil { - t.Fatal(err) - } - frames = append(frames, payload) - } - if event.Type == proto.TypeDone { - done++ + if event.Type == proto.TypeToolCall { + var payload proto.ToolCallPayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) } + frames = append(frames, payload) } - if done != 1 || observed && len(frames) != 2 || !observed && len(frames) != 0 { - t.Fatal("completion or observation opt-in changed", done, frames) - } - if observed && (frames[0].ID != "observed" || frames[1].ID != "observed" || frames[1].Observation.Status != "failed" || - string(frames[1].Observation.Output) != `"Exit code 7\nretained"`) { - t.Fatal("native failure output was not retained", frames) + if event.Type == proto.TypeDone { + done++ } } + if done != 1 || len(frames) != 2 { + t.Fatal("completion or observation changed", done, frames) + } + if frames[0].ID != "observed" || frames[1].ID != "observed" || frames[1].Observation.Status != "failed" || + string(frames[1].Observation.Output) != `"Exit code 7\nretained"` { + t.Fatal("native failure output was not retained", frames) + } } func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *testing.T) { @@ -93,7 +75,7 @@ func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *t defer cancel() config := preparationFixture(t, mode) req := workspaceRequest() - req.AgentSessionID, req.ObserveToolObservations = "native-session", true + req.AgentSessionID = "native-session" out := make(chan proto.Envelope, 32) s, err := NewFactory(config)(ctx, req, out) if err != nil { diff --git a/apps/daemon/internal/agent/claudesdk/commands_test.go b/apps/daemon/internal/agent/claudesdk/commands_test.go index 33b923c7f..f98129c51 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_test.go @@ -13,53 +13,45 @@ func commandEvent(id, stage, status, command string) bridgeEvent { Observation: &proto.ToolObservation{Kind: "command", Status: status, Command: command}} } -func TestCommandObservationLifecycleAndOptIn(t *testing.T) { - for _, observed := range []bool{false, true} { - state := commandState{calls: map[string]proto.ToolObservation{}} - start := startRequest{Workspace: &workspaceProfile{}, observeFunctions: observed} - var events []proto.ToolCallPayload - emit := func(kind string, payload any) { - if kind != proto.TypeToolCall { - t.Fatal(kind) - } - events = append(events, payload.(proto.ToolCallPayload)) - } - const command = " printf 'failure\\n'; exit 7 " - before := commandEvent("native-call", "before", "in_progress", command) - if err := state.receive(before, start, "native-session", emit); err != nil || state.complete() { - t.Fatal("call was not pending", err) - } - if err := state.receive(before, start, "native-session", emit); err == nil { - t.Fatal("duplicate bridge call accepted") - } - after := commandEvent("native-call", "after", "failed", command) - after.Observation.Output = json.RawMessage(`"Exit code 7\nfailure"`) - if err := state.receive(after, start, "native-session", emit); err != nil || !state.complete() { - t.Fatal("native result did not complete the call", err) - } - if err := state.receive(after, start, "native-session", emit); err == nil { - t.Fatal("duplicate bridge result accepted") - } - if err := state.receive(commandEvent("pending", "before", "in_progress", "sleep 30"), start, "native-session", emit); err != nil { - t.Fatal(err) - } - state.close(start, emit) - state.close(start, emit) - if !state.complete() || state.calls["pending"].Status != "incomplete" || state.calls["native-call"].Status != "failed" { - t.Fatal("closure changed an observed result or lost an unfinished call") - } - if !observed { - if len(events) != 0 { - t.Fatal("opt-out emitted observations") - } - continue - } - if len(events) != 4 || events[0].ID != "native-call" || events[0].Name != "Bash" || events[0].Observation.Command != command || - string(events[1].Observation.Output) != `"Exit code 7\nfailure"` || events[1].Observation.ExitCode != nil || - events[1].Observation.Cwd != nil || events[1].Observation.DurationMS != nil || events[3].ID != "pending" || - events[3].Observation.Status != "incomplete" || len(events[3].Observation.Output) != 0 { - t.Fatal("observation identity, output or unknown metadata changed", events) +func TestCommandObservationLifecycle(t *testing.T) { + state := commandState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}} + var events []proto.ToolCallPayload + emit := func(kind string, payload any) { + if kind != proto.TypeToolCall { + t.Fatal(kind) } + events = append(events, payload.(proto.ToolCallPayload)) + } + const command = " printf 'failure\\n'; exit 7 " + before := commandEvent("native-call", "before", "in_progress", command) + if err := state.receive(before, start, "native-session", emit); err != nil || state.complete() { + t.Fatal("call was not pending", err) + } + if err := state.receive(before, start, "native-session", emit); err == nil { + t.Fatal("duplicate bridge call accepted") + } + after := commandEvent("native-call", "after", "failed", command) + after.Observation.Output = json.RawMessage(`"Exit code 7\nfailure"`) + if err := state.receive(after, start, "native-session", emit); err != nil || !state.complete() { + t.Fatal("native result did not complete the call", err) + } + if err := state.receive(after, start, "native-session", emit); err == nil { + t.Fatal("duplicate bridge result accepted") + } + if err := state.receive(commandEvent("pending", "before", "in_progress", "sleep 30"), start, "native-session", emit); err != nil { + t.Fatal(err) + } + state.close(emit) + state.close(emit) + if !state.complete() || state.calls["pending"].Status != "incomplete" || state.calls["native-call"].Status != "failed" { + t.Fatal("closure changed an observed result or lost an unfinished call") + } + if len(events) != 4 || events[0].ID != "native-call" || events[0].Name != "Bash" || events[0].Observation.Command != command || + string(events[1].Observation.Output) != `"Exit code 7\nfailure"` || events[1].Observation.ExitCode != nil || + events[1].Observation.Cwd != nil || events[1].Observation.DurationMS != nil || events[3].ID != "pending" || + events[3].Observation.Status != "incomplete" || len(events[3].Observation.Output) != 0 { + t.Fatal("observation identity, output or unknown metadata changed", events) } } @@ -67,7 +59,7 @@ func TestCommandObservationsRejectUnqualifiedOrInconsistentEvents(t *testing.T) for _, mode := range []string{"profile", "uninitialized", "session", "id", "nil", "kind", "empty-command", "name", "cwd", "exit", "duration", "arguments", "error", "output-object", "before-output", "before-status", "after-before", "changed-command", "after-status", "stage"} { t.Run(mode, func(t *testing.T) { state := commandState{calls: map[string]proto.ToolObservation{}} - start := startRequest{Workspace: &workspaceProfile{}, observeFunctions: true} + start := startRequest{Workspace: &workspaceProfile{}} sessionID := "native-session" event := commandEvent("call", "before", "in_progress", "pwd") if strings.HasPrefix(mode, "after-") || mode == "changed-command" { @@ -130,7 +122,7 @@ func TestCommandObservationUnknownAndEmptyOutputRemainDistinct(t *testing.T) { for _, output := range []json.RawMessage{nil, json.RawMessage(`null`), json.RawMessage(`""`)} { state := commandState{calls: map[string]proto.ToolObservation{}} start := startRequest{Workspace: &workspaceProfile{}} - emit := func(string, any) { t.Fatal("opt-out emitted an observation") } + emit := func(string, any) {} if err := state.receive(commandEvent("call", "before", "in_progress", "pwd"), start, "native-session", emit); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 7ade50fba..664579186 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -48,7 +48,7 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { } func preparationOnly(req proto.PromptRequestPayload) error { - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return errors.New("claudesdk: Executor preparation cannot submit input") } return nil @@ -104,9 +104,6 @@ func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { if start.Subagents != nil && !info.SupportsSubagents() { return errors.New("claudesdk: subagent resources are unavailable") } - if start.Workspace != nil && start.observeFunctions && !info.supportsWorkspaceCommands() { - return errors.New("claudesdk: packaged runtime does not support workspace command observations") - } if start.Workspace != nil && len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { return errors.New("claudesdk: workspace functions are unavailable") } diff --git a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go index f4cb246bd..bba0b7099 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -27,6 +27,9 @@ func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { t.Fatal("initial output missing") } if mode == "pending_function" || mode == "pending_function_unconfirmed" { + if event := <-out; event.Type != proto.TypeToolCall { + t.Fatal("function observation missing") + } if event := <-out; event.Type != proto.TypeFunctionCall { t.Fatal("function obligation missing") } diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go index 63ca1e998..175618428 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -82,7 +82,7 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) } defer persist() - request := proto.PromptRequestPayload{StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: model, ModelProvider: provider, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} + request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: model, ModelProvider: provider, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} factory := NewExecutorFactory(config) prepared := time.Now() owner, err := factory(ctx, request) diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index d1b9ce48c..97c6638cf 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -197,8 +197,8 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" } - mcp.close(start, emit) - commands.close(start, emit) + mcp.close(emit) + commands.close(emit) s.stopSteering() metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} if id := s.inputSessionID(); id != "" { diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go index 80048c70b..752d1347f 100644 --- a/apps/daemon/internal/agent/claudesdk/functions.go +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -84,13 +84,11 @@ func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit fu if err != nil { return err } - if start.observeFunctions { - id, stage := event.CallID, "after" - if call != nil { - id, stage = call.CallID, "before" - } - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + id, stage := event.CallID, "after" + if call != nil { + id, stage = call.CallID, "before" } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) if call != nil { emit(proto.TypeFunctionCall, call) } else { diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index 4e44dcc08..b1fe6f442 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -22,7 +22,7 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", ObserveToolObservations: true, Model: "fake-model", SystemPrompt: "instructions", FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions", FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go index d14865c0e..bda17a491 100644 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -138,9 +138,8 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { requestStart := len(requests) mu.Unlock() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} if success != nil { - request.ObserveToolObservations = true request.SystemPrompt = "Call lookup exactly once as requested, then report both result parts and any prior verification value. Never retry a failed tool." request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} } diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index a74da05d7..b193136d7 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -109,9 +109,7 @@ func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(stri return fmt.Errorf("claudesdk: inconsistent MCP observation") } m.calls[event.ID] = *n - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) return nil } @@ -124,15 +122,13 @@ func (m *mcpState) complete() bool { return true } -func (m *mcpState) close(start startRequest, emit func(string, any)) { +func (m *mcpState) close(emit func(string, any)) { for id, call := range m.calls { if call.Status != "in_progress" { continue } call.Status = "incomplete" m.calls[id] = call - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) } } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go index 5c9e1db31..73720fe3c 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -21,6 +21,13 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, if err != nil { return nil, nil, err } + return mcpServers(bindings, localworkspace.MCPStdioCommand) +} + +// mcpServers renders resolved bindings, each stdio binding with the command +// and arguments stdio gives it and each credential in a private environment +// variable. +func mcpServers(bindings []agent.MCPBinding, stdio func(proto.EnvironmentMCP) (string, []string)) ([]environmentMCPServer, []string, error) { var servers []environmentMCPServer var env []string for _, binding := range bindings { @@ -28,7 +35,7 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, return nil, nil, fmt.Errorf("claudesdk: unsupported MCP identity") } if binding.Stdio != nil { - command, args := localworkspace.MCPStdioCommand(*binding.Stdio) + command, args := stdio(*binding.Stdio) servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: args}) continue } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index 328674950..ab84b3b9e 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -73,7 +73,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { } func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { - start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}, observeFunctions: true} + start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}} state := mcpState{calls: map[string]proto.ToolObservation{}} observation := proto.ToolObservation{Kind: "mcp", Name: "echo", Server: "installed", Status: "in_progress", Arguments: json.RawMessage(`{}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)} var emitted []proto.ToolCallPayload @@ -81,7 +81,7 @@ func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { if err := state.receive(bridgeEvent{ID: "native-call", Stage: "before", Observation: &observation}, start, emit); err != nil { t.Fatal(err) } - state.close(start, emit) + state.close(emit) if len(emitted) != 2 || emitted[1].ID != "native-call" || emitted[1].Observation.Status != "incomplete" { t.Fatal("interrupted environment call lost identity") } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index f2d259979..1bbc8af5f 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -72,7 +72,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { } func TestMCPObservationLifecycle(t *testing.T) { - start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}, observeFunctions: true} + start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}} state := mcpState{calls: map[string]proto.ToolObservation{}} var observations []proto.ToolCallPayload emit := func(kind string, payload any) { @@ -107,7 +107,7 @@ func TestMCPObservationLifecycle(t *testing.T) { if err := state.receive(before, start, emit); err != nil { t.Fatal(err) } - state.close(start, emit) + state.close(emit) if !state.complete() || observations[len(observations)-1].Observation.Status != "incomplete" { t.Fatal("cancellation lost pending call") } diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index d8532b944..d7e61beaa 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -39,7 +39,6 @@ type startRequest struct { MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` Workspace *workspaceProfile `json:"workspace,omitempty"` RequireHistory bool `json:"require_history,omitempty"` - observeFunctions bool } func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { @@ -111,7 +110,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR // an agent-host view takes from its Session rather than the request. func prepareOptions(req proto.PromptRequestPayload, mcp bool) (startRequest, []string, error) { skills := req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) != 0 - start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} + start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools} fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 6a3086c68..e11848dcd 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -97,7 +97,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "conversation", "attachments", "subagents", "none", "functions", "mcp", "controls", "old-runtime"} { + for _, name := range []string{"run", "prompt", "attachments", "subagents", "none", "functions", "mcp", "controls", "old-runtime"} { t.Run(name, func(t *testing.T) { config := preparationFixture(t, name) req := preparationRequest() @@ -106,8 +106,6 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) req.RunID = "unexpected" case "prompt": req.Input = proto.TextInput("unexpected") - case "conversation": - req.ConversationID = "product" case "attachments": req.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} case "subagents": diff --git a/apps/daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go index ab520bf04..33e24a426 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -75,16 +75,14 @@ func (info RuntimeInfo) supportsWorkspace() bool { return slices.Contains(info.Features, "workspace_tools") } +// Workspace execution always emits neutral command observations, so a bridge +// without them cannot run a prepared workspace. func (info RuntimeInfo) supportsWorkspacePreparation() bool { - return info.supportsWorkspace() && slices.Contains(info.Features, "workspace_prepare") -} - -func (info RuntimeInfo) supportsWorkspaceCommands() bool { - return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "workspace_command_observations") + return info.supportsWorkspace() && slices.Contains(info.Features, "workspace_prepare") && slices.Contains(info.Features, "workspace_command_observations") } func (info RuntimeInfo) SupportsLocalRuntime() bool { - return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v2") + return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "local_runtime_v2") } func (info RuntimeInfo) SupportsWorkspaceFunctions() bool { @@ -116,10 +114,10 @@ func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { } process, err := clirunner.Start(clirunner.StartOptions{ Parent: ctx, Binary: binary, - Args: []string{filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), config.Entrypoint}, - Dir: filepath.Dir(config.Entrypoint), - Env: env, - OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, + Args: []string{filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), config.Entrypoint}, + Dir: filepath.Dir(config.Entrypoint), + Env: env, + KillTimeout: 250 * time.Millisecond, }) if err != nil { return RuntimeInfo{}, fmt.Errorf("claudesdk: cannot start runtime check: %w", err) diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 78003305a..0d8610264 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -37,7 +37,7 @@ func NewFactory(config Config) agent.Factory { prepareExecutor := NewExecutorFactory(config) return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { run, input := req.RunID, req.Input - req.RunID, req.ConversationID, req.Input = "", "", nil + req.RunID, req.Input = "", nil resource, err := prepareExecutor(ctx, req) if err != nil { return nil, err @@ -86,7 +86,7 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if binary == "" { binary = "node" } - return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) } // startSession runs the bridge through start: clirunner.Start, or an agent-host diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index d9ca20e6e..8f0146c78 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" "slices" "strings" @@ -83,12 +84,12 @@ func declareView(probe Config, info RuntimeInfo, node, root, bridge string, load ForwardEnv: []string{"CLAUDECODE", "GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilitySupported, Skills: proto.CapabilityUnsupported, FunctionTools: proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()), FunctionResultImages: proto.CapabilityFromBool(info.SupportsFunctionResultImages()), ToolSearch: proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()), - StdioMCP: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilitySupported, }, } loader.AddTo(view) @@ -110,20 +111,23 @@ func newViewExecutorFactory(probe Config, layout viewLayout) agent.ViewExecutorF return nil, err } return startExecutor(ctx, checked, probe, start, func() (*session, error) { - return startSession(view.Launch, clirunner.StartOptions{Parent: ctx, Binary: layout.node, Args: []string{layout.bridge}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + return startSession(view.Launch, clirunner.StartOptions{Parent: ctx, Binary: layout.node, Args: []string{layout.bridge}, Dir: start.Cwd, Env: env, NeedStdin: true}) }) } } -// prepareView builds the workspace profile for one Session from the request -// and the view: the workspace is the sandbox's, MCP comes only from the view, +// prepareView builds the start request for one Session from the request and +// the view: the workspace profile in the sandbox's workspace, or no workspace +// in the work directory with environment none. MCP comes only from the view, // and the environment is closed. func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.ViewSession) (startRequest, []string, error) { environment := req.LocalEnvironment - if environment == nil || !workspacePathSyntax(environment.WorkspaceRoot) || req.DisableExecutionEnvironment || view.Launch == nil || view.Proxy == "" { - return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace, Launch and the gateway proxy") + if (environment == nil) != req.DisableExecutionEnvironment || environment != nil && !workspacePathSyntax(environment.WorkspaceRoot) || view.Launch == nil || view.Proxy == "" { + return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace or environment none, Launch and the gateway proxy") } - servers, err := viewMCP(view.MCP) + // The gateway adds each credential and header, and the Harness runs each + // stdio alias without arguments. + servers, _, err := mcpServers(view.MCP, func(stdio proto.EnvironmentMCP) (string, []string) { return stdio.Server.Command, nil }) if err != nil { return startRequest{}, nil, err } @@ -134,33 +138,24 @@ func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.V if err := viewHome(view.Home); err != nil { return startRequest{}, nil, err } - profile, env := viewEnvironment(layout, view.Home.View, view.Proxy, provider) + profile, env := viewEnvironment(layout, view.Home.View, view.Proxy, provider, environment != nil) + if environment == nil { + // Environment none has no Environment MCP, so each server is HTTP. + start.Cwd = path.Join(view.Home.View, agent.ViewWorkName) + if len(servers) != 0 { + http := make([]mcpHTTPServer, 0, len(servers)) + for _, server := range servers { + http = append(http, server.mcpHTTPServer) + } + start.MCPHTTPServers = &http + } + return start, env, nil + } profile.NetworkAccess, profile.MCP = environment.NetworkAccess, servers start.Workspace, start.Cwd = profile, environment.WorkspaceRoot return start, env, nil } -// viewMCP renders the gateway's HTTP endpoints. The gateway adds each -// credential and header, so none is rendered here. -func viewMCP(bindings []agent.MCPBinding) ([]environmentMCPServer, error) { - declarations := make([]proto.MCPHTTPServer, 0, len(bindings)) - for _, binding := range bindings { - if binding.Transport != "http" || binding.Stdio != nil { - return nil, fmt.Errorf("%w: %s MCP in an agent-host view", agent.ErrUnsupportedOperation, binding.Transport) - } - declarations = append(declarations, proto.MCPHTTPServer{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required}) - } - if err := validateMCPServers(declarations); err != nil { - return nil, err - } - projected, _ := prepareMCPHTTP(&declarations) - servers := make([]environmentMCPServer, 0, len(*projected)) - for _, server := range *projected { - servers = append(servers, environmentMCPServer{mcpHTTPServer: server}) - } - return servers, nil -} - // viewHome lays out the native directories. A later Executor finds the tree // the Session uid has owned, so every operation stays inside one os.Root and // an existing entry must be a directory, not a link. @@ -185,23 +180,28 @@ func viewHome(home agent.ViewDir) error { } // viewEnvironment is the complete Harness environment. home is the Session -// home's view path. -func viewEnvironment(layout viewLayout, home, proxy string, provider []string) (*workspaceProfile, []string) { +// home's view path, and workspace adds what the workspace tools need. +func viewEnvironment(layout viewLayout, home, proxy string, provider []string, workspace bool) (*workspaceProfile, []string) { shims := agent.ViewPrivateRoot + "/" + agent.ViewShimName profile := &workspaceProfile{Home: home + "/home", State: home + "/config", Scratch: home + "/tmp", EnvNames: []string{}, AllowedDomains: []string{}} env := []string{ "PATH=" + shims, "HOME=" + profile.Home, "TMPDIR=" + profile.Scratch, "CLAUDE_CONFIG_DIR=" + profile.State, // The messaging socket path stays local and short (C5). "XDG_RUNTIME_DIR=" + home + "/xdg", - // Bash runs the sandbox shell through the shim (C3). - "SHELL=" + shims + "/bash", "CLAUDE_CODE_SHELL=" + shims + "/bash", - // The shell's cwd file must be at the same path on both sides (C4). - "CLAUDE_CODE_TMPDIR=/tmp/oac-claude-" + strings.ToLower(rand.Text()), "CLAUDE_CODE_CERT_STORE=bundled", // C2 - "USE_BUILTIN_RIPGREP=0", // C7: rg runs through its shim "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1", // C9 "CLAUDE_CODE_TOOL_MEMORY_LIMIT=0", // C13 } + if workspace { + env = append(env, + // Bash runs the sandbox shell through the shim (C3). + "SHELL="+shims+"/bash", "CLAUDE_CODE_SHELL="+shims+"/bash", + // The shell's cwd file must be at the same path on both sides + // (C4). An empty root has no /tmp, where Claude Code creates it. + "CLAUDE_CODE_TMPDIR=/tmp/oac-claude-"+strings.ToLower(rand.Text()), + "USE_BUILTIN_RIPGREP=0", // C7: rg runs through its shim + ) + } env = append(env, nativeFlags...) if layout.libraries != "" { env = append(env, "LD_LIBRARY_PATH="+layout.libraries) diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 573aeb21c..ba6a24729 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -6,7 +6,6 @@ import ( "bufio" "context" "encoding/json" - "errors" "fmt" "io" "io/fs" @@ -55,7 +54,7 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { defer executor.Close(ctx) request := <-requests - if !slices.Contains(view.LocalExec, launched.Binary) || !slices.Equal(launched.Args, []string{agent.ViewPrivateRoot + "/claude-sdk/dist/main.js"}) || launched.Dir != "/workspace" || !launched.OwnProcessGroup { + if !slices.Contains(view.LocalExec, launched.Binary) || !slices.Equal(launched.Args, []string{agent.ViewPrivateRoot + "/claude-sdk/dist/main.js"}) || launched.Dir != "/workspace" { t.Fatalf("launch = %+v, want the closure's node running the bridge in the workspace", launched) } env := map[string]string{} @@ -106,10 +105,39 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { t.Fatal("the real key reached the view") } + // The Harness runs a stdio binding's alias without arguments. + docs := session.MCP session.MCP = []agent.MCPBinding{{ServerLabel: "local", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} - if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) { - t.Fatalf("stdio MCP = %v, want ErrUnsupportedOperation", err) + stdio, err := view.Executor(t.Context(), req, session) + if err != nil { + t.Fatal(err) + } + defer stdio.Close(ctx) + var stdioStart startRequest + if err := json.Unmarshal(<-requests, &stdioStart); err != nil || stdioStart.Workspace == nil || len(stdioStart.Workspace.MCP) != 1 || + stdioStart.Workspace.MCP[0].Command != agent.ViewAlias(0) || stdioStart.Workspace.MCP[0].Args != nil { + t.Fatalf("stdio MCP = %+v, %v", stdioStart.Workspace, err) + } + + // With environment none the bridge runs without a workspace in the work directory. + none := req + none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true + session.MCP = docs + noneExecutor, err := view.Executor(t.Context(), none, session) + if err != nil { + t.Fatal(err) + } + defer noneExecutor.Close(ctx) + var noneStart startRequest + if err := json.Unmarshal(<-requests, &noneStart); err != nil || launched.Dir != "/.oac/home/work" || noneStart.Cwd != launched.Dir || noneStart.Workspace != nil || + noneStart.MCPHTTPServers == nil || len(*noneStart.MCPHTTPServers) != 1 || (*noneStart.MCPHTTPServers)[0].ServerURL != "http://127.0.0.1:17102/mcp/docs" { + t.Fatalf("environment none launched in %q with %+v, %v", launched.Dir, noneStart, err) + } + for _, entry := range launched.Env { + if strings.HasPrefix(entry, "CLAUDE_CODE_TMPDIR=") || strings.HasPrefix(entry, "SHELL=") { + t.Errorf("environment none sets the workspace tools' %s", entry) + } } // A link the Session uid planted in its home is never followed. diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index 810bbd095..b95cda8fd 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -24,7 +24,7 @@ test -z "${ANTHROPIC_AUTH_TOKEN+x}" && test "$HTTPS_PROXY" = http://proxy.exampl test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 case "$1" in */runtime_check.js) - printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; + printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare","workspace_command_observations"]}' ;; *) IFS= read -r request printf '%s\n' '{"type":"executor_ready","protocol":3}' @@ -59,7 +59,7 @@ esac t.Fatal("expected one settled completion") } // Feature checking must reject an older bridge without starting execution. - script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare"]`, `"features":[]`) + script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare","workspace_command_observations"]`, `"features":[]`) script = strings.ReplaceAll(script, "IFS= read -r request", "touch '"+filepath.Join(config.StateDir, "unexpected-start")+"'") if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index f391878a9..d9a7e3321 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -91,7 +91,7 @@ func TestLiveClaudeWorkspaceFactory(t *testing.T) { out := make(chan proto.Envelope, 64) req := workspaceRequest() req.RunID, req.Input, req.AgentSessionID = uuid.NewString(), proto.TextInput(prompt), resume - req.StrictResume, req.ReleaseOnCompletion, req.ObserveMessages, req.ObserveToolObservations = true, true, true, true + req.ObserveMessages = true req.Model, req.SystemPrompt = "MiniMax-M3", "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work." proof := evidence{RunID: req.RunID} running, err := NewFactory(config)(ctx, req, out) diff --git a/apps/daemon/internal/agent/clirunner/handle.go b/apps/daemon/internal/agent/clirunner/handle.go index e331ec3ca..4c0ce3cf4 100644 --- a/apps/daemon/internal/agent/clirunner/handle.go +++ b/apps/daemon/internal/agent/clirunner/handle.go @@ -44,7 +44,7 @@ func FromHandle(h Handle, opts HandleOptions) (*Process, error) { opts.KillTimeout = DefaultKillTimeout } ctx, cancel := context.WithCancel(opts.Parent) - p := &Process{Stdin: opts.Stdin, Stdout: opts.Stdout, Stderr: opts.Stderr, ctx: ctx, cancel: cancel, done: make(chan struct{}), killAfter: opts.KillTimeout} + p := &Process{Stdin: opts.Stdin, Stdout: opts.Stdout, Stderr: opts.Stderr, ctx: ctx, cancel: cancel, done: make(chan struct{})} var terminateOnce sync.Once var interrupted atomic.Bool p.cancelProcess = func() error { @@ -53,7 +53,7 @@ func FromHandle(h Handle, opts HandleOptions) (*Process, error) { interrupted.Store(err == nil) go func() { if err == nil || errors.Is(err, os.ErrProcessDone) { - timer := time.NewTimer(p.killAfter) + timer := time.NewTimer(opts.KillTimeout) defer timer.Stop() select { case <-p.done: diff --git a/apps/daemon/internal/agent/clirunner/process.go b/apps/daemon/internal/agent/clirunner/process.go index fa4064012..30bd5d5e9 100644 --- a/apps/daemon/internal/agent/clirunner/process.go +++ b/apps/daemon/internal/agent/clirunner/process.go @@ -5,9 +5,7 @@ import ( "fmt" "io" "os/exec" - "runtime" "sync" - "syscall" "time" ) @@ -23,8 +21,6 @@ type StartOptions struct { Env []string NeedStdin bool KillTimeout time.Duration - // OwnProcessGroup bounds the lifetime of subprocess descendants on Unix. - OwnProcessGroup bool } type Process struct { @@ -33,13 +29,11 @@ type Process struct { Stdout io.ReadCloser Stderr io.ReadCloser - ctx context.Context - cancel context.CancelFunc - done chan struct{} - killAfter time.Duration + ctx context.Context + cancel context.CancelFunc + done chan struct{} cancelOnce sync.Once - waitOnce sync.Once cancelProcess func() error waitProcess func() error @@ -59,54 +53,9 @@ func Start(opts StartOptions) (*Process, error) { opts.KillTimeout = DefaultKillTimeout } - if opts.OwnProcessGroup || runtime.GOOS == "windows" { - return startProcessGroup(opts) - } - - ctx, cancel := context.WithCancel(opts.Parent) - cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) - cmd.Dir = opts.Dir - if len(opts.Env) > 0 { - cmd.Env = append([]string{}, opts.Env...) - } - - var stdin io.WriteCloser - var err error - if opts.NeedStdin { - stdin, err = cmd.StdinPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("clirunner: stdin pipe: %w", err) - } - } - stdout, err := cmd.StdoutPipe() - if err != nil { - closePipe(stdin) - cancel() - return nil, fmt.Errorf("clirunner: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - closePipe(stdin) - cancel() - return nil, fmt.Errorf("clirunner: stderr pipe: %w", err) - } - if err := cmd.Start(); err != nil { - closePipe(stdin) - cancel() - return nil, fmt.Errorf("clirunner: start %q: %w", opts.Binary, err) - } - - return &Process{ - Cmd: cmd, - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - ctx: ctx, - cancel: cancel, - done: make(chan struct{}), - killAfter: opts.KillTimeout, - }, nil + // Every child owns its process group (a Job object on Windows), bounding the + // lifetime of its descendants. + return startProcessGroup(opts) } func (p *Process) Context() context.Context { @@ -126,44 +75,20 @@ func (p *Process) Done() <-chan struct{} { } func (p *Process) Cancel() { - if p == nil { + if p == nil || p.cancelProcess == nil { return } p.cancelOnce.Do(func() { - if p.cancelProcess != nil { - _ = p.cancelProcess() - p.cancel() - return - } - if p.Cmd != nil && p.Cmd.Process != nil { - _ = p.Cmd.Process.Signal(syscall.SIGTERM) - go func() { - select { - case <-p.done: - case <-time.After(p.killAfter): - _ = p.Cmd.Process.Signal(syscall.SIGKILL) - } - }() - } - if p.cancel != nil { - p.cancel() - } + _ = p.cancelProcess() + p.cancel() }) } func (p *Process) Wait() error { - if p == nil { - return nil - } - if p.waitProcess != nil { - return p.waitProcess() - } - if p.Cmd == nil { + if p == nil || p.waitProcess == nil { return nil } - err := p.Cmd.Wait() - p.waitOnce.Do(func() { close(p.done) }) - return err + return p.waitProcess() } // ExitCode returns the exit code once Done is closed, or -1 when a signal ended the process. ok is false before then and when the exit is unknown. diff --git a/apps/daemon/internal/agent/clirunner/process_group_other.go b/apps/daemon/internal/agent/clirunner/process_group_other.go index d5763742b..796f8aa19 100644 --- a/apps/daemon/internal/agent/clirunner/process_group_other.go +++ b/apps/daemon/internal/agent/clirunner/process_group_other.go @@ -5,5 +5,5 @@ package clirunner import "errors" func startProcessGroup(StartOptions) (*Process, error) { - return nil, errors.New("clirunner: process-group ownership requires Unix") + return nil, errors.New("clirunner: process ownership requires Unix or Windows") } diff --git a/apps/daemon/internal/agent/clirunner/process_group_unix_test.go b/apps/daemon/internal/agent/clirunner/process_group_unix_test.go index 8a3cbf162..da3162be5 100644 --- a/apps/daemon/internal/agent/clirunner/process_group_unix_test.go +++ b/apps/daemon/internal/agent/clirunner/process_group_unix_test.go @@ -107,9 +107,9 @@ func startOwnedHelper(t *testing.T, ctx context.Context, mode, dir string) *Proc t.Helper() p, err := Start(StartOptions{ Parent: ctx, Binary: os.Args[0], - Args: []string{"-test.run=^TestOwnedGroupHelper$", "--", "leader", mode, dir}, - Env: append(os.Environ(), "GO_WANT_OWNED_GROUP=1", "GORACE=atexit_sleep_ms=0"), - OwnProcessGroup: true, KillTimeout: 300 * time.Millisecond, + Args: []string{"-test.run=^TestOwnedGroupHelper$", "--", "leader", mode, dir}, + Env: append(os.Environ(), "GO_WANT_OWNED_GROUP=1", "GORACE=atexit_sleep_ms=0"), + KillTimeout: 300 * time.Millisecond, }) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/clirunner/process_group_windows_test.go b/apps/daemon/internal/agent/clirunner/process_group_windows_test.go index b2d442b54..fee66f0f2 100644 --- a/apps/daemon/internal/agent/clirunner/process_group_windows_test.go +++ b/apps/daemon/internal/agent/clirunner/process_group_windows_test.go @@ -21,7 +21,7 @@ func TestWindowsOwnedTree(t *testing.T) { dir := t.TempDir() ctx, cancel := context.WithCancel(t.Context()) defer cancel() - p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1"), OwnProcessGroup: true}) + p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1")}) if err != nil { t.Fatal(err) } @@ -129,7 +129,7 @@ func TestWindowsTreeHelper(t *testing.T) { time.Sleep(10 * time.Millisecond) } case "owner": - p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ(), OwnProcessGroup: true}) + p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ()}) if err != nil { os.Exit(3) } diff --git a/apps/daemon/internal/agent/codex/environment.go b/apps/daemon/internal/agent/codex/environment.go index 73e1e1c98..725e5ef58 100644 --- a/apps/daemon/internal/agent/codex/environment.go +++ b/apps/daemon/internal/agent/codex/environment.go @@ -38,8 +38,8 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) } // Native still recognizes the retired transport variables. Reject them before -// setup so an inherited environment cannot select a separate executor. The -// explicit none selector remains part of native execution isolation. +// setup so the inherited environment cannot select a separate executor. +// The explicit none selector remains part of native execution isolation. func validateNativeTransportEnvironment() error { for _, entry := range os.Environ() { key, value, _ := strings.Cut(entry, "=") diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index ee97e44ed..182a48fb0 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -1,6 +1,7 @@ package codex import ( + "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -22,18 +23,10 @@ func TestExecutionControlsSelectNativeSettings(t *testing.T) { if err != nil { t.Fatal(err) } - want := map[string]string{"web_search": `"` + search + `"`, "model_verbosity": `"` + verbosity + `"`} - for _, kv := range plan.ExtraConfig { - if v, ok := want[kv[0]]; ok { - if kv[1] != v { - t.Fatal("native control differs", kv) - } - delete(want, kv[0]) - } - } plan.Cleanup() - if len(want) != 0 { - t.Fatal("native settings omitted", want) + want := [][2]string{{"web_search", `"` + search + `"`}, {"model_verbosity", `"` + verbosity + `"`}, {"model_provider", `"` + oacProviderSlug + `"`}} + if !reflect.DeepEqual(plan.ExtraConfig, want) { + t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) } } } diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 2f1e2eb09..75403c57d 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -69,8 +69,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} turnCtx, cancel := context.WithCancel(base.cancelCtx) s := &Session{executor: e, nativeHome: base.nativeHome, - functions: functions, observeMessages: base.observeMessages, - observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, + functions: functions, observeMessages: base.observeMessages, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, interactions: newPendingCodexInteractions(), runID: runID, out: out} @@ -94,7 +93,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess } s.registerHandlers() e.mu.Unlock() - req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, StrictResume: e.prepared.strictResume, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} + req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} // Ownership precedes any native submission. Even an uncertain start returns the // exact Turn so its caller can await settlement without replaying the input. err := s.startNative(ctx, e.prepared.plan, req) diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go index cb65df39a..9505d4b50 100644 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -51,7 +51,7 @@ func TestExecutorNativeReuse(t *testing.T) { cfg.logger = obslog.Discard() req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "executor-native", - StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, + DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, Model: model, ModelProvider: &modelprovider.Provider{BaseURL: endpoint, Protocol: modelprovider.Responses, APIKey: strings.TrimSpace(string(key))}, FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, diff --git a/apps/daemon/internal/agent/codex/executor_test.go b/apps/daemon/internal/agent/codex/executor_test.go index dd8d4f518..954f00869 100644 --- a/apps/daemon/internal/agent/codex/executor_test.go +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -165,7 +165,7 @@ func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { } func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index c86df681f..0b1461a11 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -21,12 +21,13 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf if bindings == nil { return nil, nil, nil } - return mcpServersFromBindings(bindings) + return mcpServersFromBindings(bindings, localworkspace.MCPStdioCommand) } -// mcpServersFromBindings renders resolved bindings, with each credential in a -// private environment variable. -func mcpServersFromBindings(bindings []agent.MCPBinding) (map[string]mcpServerConfig, []string, error) { +// mcpServersFromBindings renders resolved bindings, each stdio binding with +// the command and arguments stdio gives it and each credential in a private +// environment variable. +func mcpServersFromBindings(bindings []agent.MCPBinding, stdio func(proto.EnvironmentMCP) (string, []string)) (map[string]mcpServerConfig, []string, error) { servers := make(map[string]mcpServerConfig, len(bindings)) var env []string for _, binding := range bindings { @@ -35,7 +36,7 @@ func mcpServersFromBindings(bindings []agent.MCPBinding) (map[string]mcpServerCo } server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, ApproveTools: binding.ConnectionOrigin == "environment"} if binding.Stdio != nil { - server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) + server.Command, server.Args = stdio(*binding.Stdio) } if binding.BearerToken != nil { server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() @@ -71,7 +72,6 @@ func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { return errors.New("codex: cannot write public MCP configuration") } for _, feature := range []string{"plugins", "apps"} { - 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) } diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 9a9a7732d..0b62c9b87 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -26,7 +26,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatal(err) } defer plan.Cleanup() - if slices.Contains(plan.EnableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { + if !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { t.Fatal("native profile was not pinned") } home, err := allocCodexHome(req.AgentStateKey) diff --git a/apps/daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go index 70f126310..b488c4aae 100644 --- a/apps/daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -17,7 +17,6 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. for _, mode := range []string{"new ready", "new failed", "resume ready", "resume failed"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) - req.StrictResume = true req.ExecutionControls = nil servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} req.MCPHTTPServers = &servers diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index b86683063..a8e904194 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -19,8 +19,9 @@ import ( // the daemon's PromptRequestPayload. type SessionPlan struct { // Cwd is the working directory passed to codex and the spawned - // app-server: the bound workspace root for an Environment request and - // the Session's private CODEX_HOME for environment:none. + // app-server: the bound workspace root for an Environment request and, + // for environment:none, the Session's private CODEX_HOME or a view's work + // directory. Cwd string // Env is the environment slice (KEY=value) the plan adds. A local @@ -29,12 +30,11 @@ type SessionPlan struct { Env []string // ExtraConfig is a list of `-c key=value` overrides applied at the - // app-server CLI. Used to layer web_search, model_verbosity and - // model_reasoning_effort without editing config.toml. + // app-server CLI. ExtraConfig [][2]string // EnableFeatures / DisableFeatures forward to `--enable / --disable` - // flags. + // flags for the profiles the adapter configures. EnableFeatures []string DisableFeatures []string @@ -59,9 +59,6 @@ type SessionPlan struct { // SystemPrompt is forwarded as developerInstructions on thread/start. SystemPrompt string - // CollaborationMode selects Codex's default or plan tool surface. - CollaborationMode CollaborationModeKind - // ModelReasoningEffort is frozen for launch and every native Turn. ModelReasoningEffort string @@ -88,10 +85,9 @@ func BuildSessionPlan(req proto.PromptRequestPayload) (SessionPlan, error) { // only after the request validates. func buildSessionPlan(req proto.PromptRequestPayload, allocHome func() (agent.ViewDir, error)) (SessionPlan, error) { plan := SessionPlan{ - CollaborationMode: CollaborationModeDefault, - ApprovalPolicy: AskForApproval{String: "never"}, - Sandbox: SandboxDangerFullAcces, - Cleanup: func() {}, + ApprovalPolicy: AskForApproval{String: "never"}, + Sandbox: SandboxDangerFullAcces, + Cleanup: func() {}, } prepared, err := harnessconfiguration.Configuration().Prepare(req) if err != nil { @@ -106,7 +102,7 @@ func buildSessionPlan(req proto.PromptRequestPayload, allocHome func() (agent.Vi switch controls.TextVerbosity { case "low", "medium", "high": default: - return plan, fmt.Errorf("codex: model_verbosity must be low, medium or high") + return plan, fmt.Errorf("codex: text_verbosity must be low, medium or high") } plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"web_search", strconv(controls.WebSearch)}, [2]string{"model_verbosity", strconv(controls.TextVerbosity)}) } @@ -122,6 +118,9 @@ func buildSessionPlan(req proto.PromptRequestPayload, allocHome func() (agent.Vi } plan.home = home plan.Env = []string{"DISABLE_TELEMETRY=1", "CODEX_HOME=" + home.View} + if req.DisableExecutionEnvironment { + plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") + } if err := writeCodexProviderConfig(home.Host, nativeProvider(prepared.Provider)); err != nil { return plan, err } diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index b0263b13e..f04f77ce9 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -79,7 +79,7 @@ func TestBuildSessionPlan_CarriesModelAndSystemPrompt(t *testing.T) { t.Fatal(err) } defer plan.Cleanup() - if plan.CollaborationMode != CollaborationModeDefault || plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { + if plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { t.Fatalf("plan did not carry the default turn instructions: %+v", plan) } } diff --git a/apps/daemon/internal/agent/codex/permission_profile.go b/apps/daemon/internal/agent/codex/permission_profile.go index 46911ca67..f5e7cb807 100644 --- a/apps/daemon/internal/agent/codex/permission_profile.go +++ b/apps/daemon/internal/agent/codex/permission_profile.go @@ -6,9 +6,9 @@ import ( ) // Runtime execution uses the current user; isolation belongs to its outer host. -func runtimePermissionProfile(req proto.PromptRequestPayload) (string, error) { +func validatePermissionProfile(req proto.PromptRequestPayload) error { if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || req.WorkspaceReadOnly || req.LocalEnvironment.NetworkAccess != "enabled" || len(req.LocalEnvironment.AllowedDomains) != 0) { - return "", fmt.Errorf("codex: Runtime execution requires unrestricted host access") + return fmt.Errorf("codex: Runtime execution requires unrestricted host access") } - return "", nil + return nil } diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 3a072c808..402794dbf 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -16,7 +16,6 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan return nil, errors.New("codex: nil out channel") } runID, prompt := req.RunID, req.Input - req.AgentStateKey = effectiveAgentStateKey(req) req.RunID, req.Input = "", nil prepared, err := newPreparation(parent, req, cfg) if err != nil { @@ -33,9 +32,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if req.WorkspaceReadOnly { return nil, errors.New("codex: workspace reads use the local Runtime interface") } - if req.RequireExistingNativeSession && (!req.StrictResume || req.AgentStateKey == "" || req.WorkspaceReadOnly) { - return nil, errors.New("codex: native-session recovery requires strict private state") - } if req.RunID != "" || len(req.Input) != 0 { return nil, errors.New("codex: preparation does not accept a run identity or prompt") } @@ -52,7 +48,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if err != nil { return nil, err } - req.AgentStateKey = effectiveAgentStateKey(req) var plan SessionPlan var skillRoots []string if cfg.view != nil { @@ -85,11 +80,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg rpc := NewJSONRPCClient(rpcCfg) s := &Session{ - nativeHome: plan.home, - functions: functions, - observeMessages: req.ObserveMessages, - - observeToolObservations: req.ObserveToolObservations, + nativeHome: plan.home, + functions: functions, + observeMessages: req.ObserveMessages, observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, rpc: rpc, @@ -104,7 +97,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg plan.Cleanup = s.cleanup p := &Prepared{ session: s, plan: plan, - resumeID: req.AgentSessionID, strictResume: req.StrictResume, requireExistingNativeSession: req.RequireExistingNativeSession, + resumeID: req.AgentSessionID, requireExistingNativeSession: req.RequireExistingNativeSession, transferred: make(chan struct{}), } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index f21fe49e3..3c950524f 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -43,7 +43,6 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig cfg.codexBinary = binary req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", - ReleaseOnCompletion: true, StrictResume: true, Model: "fixture-model", ModelProvider: fixtureProvider(), ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, diff --git a/apps/daemon/internal/agent/codex/prepared.go b/apps/daemon/internal/agent/codex/prepared.go index edae1d169..a8a57c30d 100644 --- a/apps/daemon/internal/agent/codex/prepared.go +++ b/apps/daemon/internal/agent/codex/prepared.go @@ -17,7 +17,6 @@ type Prepared struct { session *Session plan SessionPlan resumeID string - strictResume bool requireExistingNativeSession bool claimed bool closed bool @@ -60,7 +59,7 @@ func (p *Prepared) start(ctx context.Context, runID string, prompt proto.Message p.started = true close(p.transferred) transferred = true - req := proto.PromptRequestPayload{RunID: runID, Input: prompt, AgentSessionID: p.resumeID, StrictResume: p.strictResume, RequireExistingNativeSession: p.requireExistingNativeSession} + req := proto.PromptRequestPayload{RunID: runID, Input: prompt, AgentSessionID: p.resumeID, RequireExistingNativeSession: p.requireExistingNativeSession} go s.run(p.plan, req) return s, nil } diff --git a/apps/daemon/internal/agent/codex/programmatic_tools.go b/apps/daemon/internal/agent/codex/programmatic_tools.go index e0d377fa0..c2da9f9a1 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools.go @@ -16,7 +16,6 @@ func disableProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionContro return } for _, feature := range programmaticFeatures { - 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) } diff --git a/apps/daemon/internal/agent/codex/programmatic_tools_test.go b/apps/daemon/internal/agent/codex/programmatic_tools_test.go index df9064dc4..fd0aeee8a 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools_test.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools_test.go @@ -3,7 +3,6 @@ package codex import ( "context" "encoding/json" - "reflect" "slices" "testing" "time" @@ -12,16 +11,12 @@ import ( ) func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { - plan := SessionPlan{EnableFeatures: []string{"code_mode", "unrelated", "code_mode_only", "code_mode_prewarm"}, ExtraConfig: [][2]string{{"features.code_mode", "true"}}} - before := slices.Clone(plan.EnableFeatures) + plan := SessionPlan{ExtraConfig: [][2]string{{"features.code_mode", "true"}}} disableProgrammaticTools(&plan, nil) - if !reflect.DeepEqual(plan.EnableFeatures, before) { + if len(plan.DisableFeatures) != 0 || len(plan.ExtraConfig) != 1 { t.Fatal("omission changed native configuration") } disableProgrammaticTools(&plan, &proto.ExecutionControls{DisableProgrammaticToolCalling: true}) - if !slices.Equal(plan.EnableFeatures, []string{"unrelated"}) { - t.Fatal(plan.EnableFeatures) - } for _, feature := range programmaticFeatures { if !slices.Contains(plan.DisableFeatures, feature) { t.Fatal("missing disable", feature) diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 2aa988740..462c10f3e 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -238,10 +238,7 @@ type TurnStartParams struct { type CollaborationModeKind string -const ( - CollaborationModePlan CollaborationModeKind = "plan" - CollaborationModeDefault CollaborationModeKind = "default" -) +const CollaborationModeDefault CollaborationModeKind = "default" type CollaborationMode struct { Mode CollaborationModeKind `json:"mode"` diff --git a/apps/daemon/internal/agent/codex/protocol_wire_test.go b/apps/daemon/internal/agent/codex/protocol_wire_test.go index 5a513043b..d32590d7c 100644 --- a/apps/daemon/internal/agent/codex/protocol_wire_test.go +++ b/apps/daemon/internal/agent/codex/protocol_wire_test.go @@ -106,13 +106,13 @@ func TestThreadStartParams_ModelProviderIsCamelCaseField(t *testing.T) { } } -func TestTurnStartParams_CollaborationModeUsesPlanWireShape(t *testing.T) { +func TestTurnStartParams_CollaborationModeWireShape(t *testing.T) { developerInstructions := "stay within the configured workspace" params := TurnStartParams{ ThreadID: "thread-1", Input: []UserInput{{Type: UserInputText, Text: "ask me a question"}}, CollaborationMode: &CollaborationMode{ - Mode: CollaborationModePlan, + Mode: CollaborationModeDefault, Settings: CollaborationModeSettings{ Model: "MiniMax-M3", DeveloperInstructions: &developerInstructions, @@ -124,7 +124,7 @@ func TestTurnStartParams_CollaborationModeUsesPlanWireShape(t *testing.T) { t.Fatalf("marshal: %v", err) } body := string(raw) - if !strings.Contains(body, `"collaborationMode":{"mode":"plan","settings":{"model":"MiniMax-M3","developer_instructions":"stay within the configured workspace"}}`) { + if !strings.Contains(body, `"collaborationMode":{"mode":"default","settings":{"model":"MiniMax-M3","developer_instructions":"stay within the configured workspace"}}`) { t.Fatalf("collaboration mode missing or malformed: %s", body) } if strings.Contains(body, `"collaboration_mode"`) { diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index 49713413e..97eff6936 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -47,7 +47,7 @@ func TestRequiredHistoryResolution(t *testing.T) { case "wrong-home": row["path"] = "/another/sessions/rollout.jsonl" } - req := proto.PromptRequestPayload{StrictResume: true, RequireExistingNativeSession: true} + req := proto.PromptRequestPayload{RequireExistingNativeSession: true} if scenario == "fresh" { req.RequireExistingNativeSession = false } @@ -197,14 +197,12 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { } } -func TestRecoveryRequiresStrictPrivateExecution(t *testing.T) { - for _, mode := range []string{"non-strict", "no-state", "read-only"} { +func TestRecoveryRequiresWritableAgentState(t *testing.T) { + for _, mode := range []string{"no-state", "read-only"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.RequireExistingNativeSession = true switch mode { - case "non-strict": - req.StrictResume = false case "no-state": req.AgentStateKey = "" case "read-only": diff --git a/apps/daemon/internal/agent/codex/resume.go b/apps/daemon/internal/agent/codex/resume.go index 6ee852b1c..2bc3172e1 100644 --- a/apps/daemon/internal/agent/codex/resume.go +++ b/apps/daemon/internal/agent/codex/resume.go @@ -9,18 +9,12 @@ import ( func (s *Session) resolveThread(req proto.PromptRequestPayload, plan SessionPlan) error { if strings.TrimSpace(req.AgentSessionID) != "" { - if err := s.resumeThread(req.AgentSessionID, plan); err == nil { - return nil - } else if req.StrictResume { + if err := s.resumeThread(req.AgentSessionID, plan); err != nil { return fmt.Errorf("codex: thread/resume: %w", err) - } else { - s.cfg.logger.Warn("codex: thread/resume failed; starting fresh", "run_id", s.runID, "thread_id", req.AgentSessionID, "err", err) } + return nil } if req.RequireExistingNativeSession { - if !req.StrictResume { - return fmt.Errorf("codex: recovery requires strict resume") - } id, err := s.recoverRoot(plan) if err != nil { return err diff --git a/apps/daemon/internal/agent/codex/resume_test.go b/apps/daemon/internal/agent/codex/resume_test.go index 1da859d76..698eb2cc7 100644 --- a/apps/daemon/internal/agent/codex/resume_test.go +++ b/apps/daemon/internal/agent/codex/resume_test.go @@ -10,53 +10,38 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) -func TestStrictResumeDoesNotStartFresh(t *testing.T) { - for _, strict := range []bool{false, true} { - t.Run(map[bool]string{false: "legacy", true: "strict"}[strict], func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "resume-test")}} - result := make(chan error, 1) - go func() { - result <- s.resolveThread(proto.PromptRequestPayload{AgentSessionID: "existing", StrictResume: strict}, SessionPlan{}) - }() - var req struct { - ID string `json:"id"` - Method string `json:"method"` - } - decoder := json.NewDecoder(server.FromClient) - encoder := json.NewEncoder(server.ToClient) - if err := decoder.Decode(&req); err != nil { - t.Fatal(err) - } - if req.Method != "thread/resume" { - t.Fatal(req.Method) - } - if err := encoder.Encode(map[string]any{"id": req.ID, "error": map[string]any{"code": -32600, "message": "native history unavailable"}}); err != nil { - t.Fatal(err) - } - if !strict { - if err := decoder.Decode(&req); err != nil { - t.Fatal(err) - } - if req.Method != "thread/start" { - t.Fatal(req.Method) - } - if err := encoder.Encode(map[string]any{"id": req.ID, "result": map[string]any{"thread": map[string]string{"id": "fresh"}}}); err != nil { - t.Fatal(err) - } - } - select { - case err := <-result: - if (err != nil) != strict { - t.Fatalf("strict=%v err=%v", strict, err) - } - case <-ctx.Done(): - t.Fatal("thread resolution did not finish") - } - }) +func TestFailedResumeDoesNotStartFresh(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "resume-test")}} + result := make(chan error, 1) + go func() { + result <- s.resolveThread(proto.PromptRequestPayload{AgentSessionID: "existing"}, SessionPlan{}) + }() + var req struct { + ID string `json:"id"` + Method string `json:"method"` + } + decoder := json.NewDecoder(server.FromClient) + encoder := json.NewEncoder(server.ToClient) + if err := decoder.Decode(&req); err != nil { + t.Fatal(err) + } + if req.Method != "thread/resume" { + t.Fatal(req.Method) + } + if err := encoder.Encode(map[string]any{"id": req.ID, "error": map[string]any{"code": -32600, "message": "native history unavailable"}}); err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err == nil { + t.Fatal("failed resume started a fresh thread") + } + case <-ctx.Done(): + t.Fatal("thread resolution did not finish") } } diff --git a/apps/daemon/internal/agent/codex/rpc.go b/apps/daemon/internal/agent/codex/rpc.go index 2d2eceb56..f3a99cf41 100644 --- a/apps/daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -186,7 +186,7 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (Initi } 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, + NeedStdin: true, KillTimeout: 250 * time.Millisecond, }) if err != nil { return InitializeResult{}, fmt.Errorf("codex rpc: spawn %q: %w", c.cfg.Binary, err) diff --git a/apps/daemon/internal/agent/codex/rpc_close_test.go b/apps/daemon/internal/agent/codex/rpc_close_test.go index 5fd3aaefd..fbd9c8175 100644 --- a/apps/daemon/internal/agent/codex/rpc_close_test.go +++ b/apps/daemon/internal/agent/codex/rpc_close_test.go @@ -15,7 +15,7 @@ import ( ) func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 0e5798581..27ab44063 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -40,9 +40,8 @@ func defaultSessionConfig() sessionConfig { } // Factory implements agent.Factory for agent_kind="codex". Spawns one -// codex app-server child per prompt. The run stream closes when the turn -// completes; the router retains the child until the conversation's idle -// window expires or cancellation shuts it down sooner. +// codex app-server child for one Turn. The run stream closes when the turn +// completes; the child remains until the caller cancels the Session. func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { return newSession(ctx, req, out, defaultSessionConfig()) } @@ -73,11 +72,10 @@ type Session struct { functions *functionCalls observeMessages bool - observeToolObservations bool - runID string - cfg sessionConfig - out chan<- proto.Envelope - rpc *JSONRPCClient + runID string + cfg sessionConfig + out chan<- proto.Envelope + rpc *JSONRPCClient cancelCtx context.Context cancelFn context.CancelFunc diff --git a/apps/daemon/internal/agent/codex/session_command_output.go b/apps/daemon/internal/agent/codex/session_command_output.go index 600e4fdad..1d5cd9f92 100644 --- a/apps/daemon/internal/agent/codex/session_command_output.go +++ b/apps/daemon/internal/agent/codex/session_command_output.go @@ -7,9 +7,6 @@ import ( ) func (s *Session) onCommandOutput(raw json.RawMessage) { - if !s.observeToolObservations { - return - } var p AgentMessageDeltaNotification if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.TurnID) || p.ItemID == "" || p.Delta == "" { return diff --git a/apps/daemon/internal/agent/codex/session_command_output_test.go b/apps/daemon/internal/agent/codex/session_command_output_test.go index 4f86ccbd0..a51fb6694 100644 --- a/apps/daemon/internal/agent/codex/session_command_output_test.go +++ b/apps/daemon/internal/agent/codex/session_command_output_test.go @@ -8,42 +8,34 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestCommandOutputRequiresOptInAndRootTurn(t *testing.T) { - for _, enabled := range []bool{false, true} { - out := make(chan proto.Envelope, 10) - s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{}), observeToolObservations: enabled} - s.registerHandlers() - s.setThreadID("root") - s.beginRootTurn("root", "turn") - for _, raw := range []string{ - `{"threadId":"child","turnId":"turn","itemId":"cmd","delta":"foreign"}`, - `{"threadId":"root","turnId":"old","itemId":"cmd","delta":"foreign"}`, - `{"threadId":"root","turnId":"turn","delta":"missing identity"}`, - `{"threadId":"root","turnId":"turn","itemId":"cmd","delta":null}`, - `{"threadId":42}`, `{}`, - } { - scopeNotification(t, s, "item/commandExecution/outputDelta", raw) - } - if len(out) != 0 { - t.Fatal("invalid output reached root") +func TestCommandOutputRequiresRootTurn(t *testing.T) { + out := make(chan proto.Envelope, 10) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{})} + s.registerHandlers() + s.setThreadID("root") + s.beginRootTurn("root", "turn") + for _, raw := range []string{ + `{"threadId":"child","turnId":"turn","itemId":"cmd","delta":"foreign"}`, + `{"threadId":"root","turnId":"old","itemId":"cmd","delta":"foreign"}`, + `{"threadId":"root","turnId":"turn","delta":"missing identity"}`, + `{"threadId":"root","turnId":"turn","itemId":"cmd","delta":null}`, + `{"threadId":42}`, `{}`, + } { + scopeNotification(t, s, "item/commandExecution/outputDelta", raw) + } + if len(out) != 0 { + t.Fatal("invalid output reached root") + } + for _, fragment := range []string{"same\n", "same\n", "结束\n"} { + raw, _ := json.Marshal(map[string]string{"threadId": "root", "turnId": "turn", "itemId": "cmd", "delta": fragment}) + scopeNotification(t, s, "item/commandExecution/outputDelta", string(raw)) + if len(out) != 1 { + t.Fatal("missing registered command notification") } - for _, fragment := range []string{"same\n", "same\n", "结束\n"} { - raw, _ := json.Marshal(map[string]string{"threadId": "root", "turnId": "turn", "itemId": "cmd", "delta": fragment}) - scopeNotification(t, s, "item/commandExecution/outputDelta", string(raw)) - if !enabled { - if len(out) != 0 { - t.Fatal("product frame sequence changed") - } - continue - } - if len(out) != 1 { - t.Fatal("missing registered command notification") - } - env := <-out - var p proto.CommandOutputPayload - if env.DecodePayload(&p) != nil || env.Type != proto.TypeCommandOutput || env.ID != "run" || p.ID != "cmd" || p.Delta != fragment { - t.Fatal("command fragment changed", env) - } + env := <-out + var p proto.CommandOutputPayload + if env.DecodePayload(&p) != nil || env.Type != proto.TypeCommandOutput || env.ID != "run" || p.ID != "cmd" || p.Delta != fragment { + t.Fatal("command fragment changed", env) } } } diff --git a/apps/daemon/internal/agent/codex/session_notifications_test.go b/apps/daemon/internal/agent/codex/session_notifications_test.go index 0df73e0e1..0ee2a9b94 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_test.go @@ -13,8 +13,7 @@ func TestRootNotificationIsolation(t *testing.T) { t.Run(map[bool]string{false: "child without thread started", true: "child thread started"}[childStarted], func(t *testing.T) { out := make(chan proto.Envelope, 64) s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), - rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true, - observeToolObservations: true} + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true} s.registerHandlers() s.setThreadID("root") notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index e4f3b9ea7..ea99f3174 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -13,8 +13,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err := validateNativeTransportEnvironment(); err != nil { return SessionPlan{}, nil, err } - _, err := runtimePermissionProfile(req) - if err != nil { + if err := validatePermissionProfile(req); err != nil { return SessionPlan{}, nil, err } mcpServers, mcpEnv, err := runtimeMCPServers(req) @@ -35,7 +34,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Sandbox = "danger-full-access" plan.Permissions = "" plan.ApprovalPolicy = AskForApproval{String: "never"} - } else if req.DisableExecutionEnvironment { + } else { // environment:none has no workspace; the Session's private home is its cwd. plan.Cwd = plan.home.View } @@ -71,9 +70,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } } - if req.DisableExecutionEnvironment { - plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") - } var skillRoots []string if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { if err := verifyHostedSkills(req.LocalEnvironment.Skills); err != nil { diff --git a/apps/daemon/internal/agent/codex/session_policy_test.go b/apps/daemon/internal/agent/codex/session_policy_test.go index fcf9a0486..0d9be040a 100644 --- a/apps/daemon/internal/agent/codex/session_policy_test.go +++ b/apps/daemon/internal/agent/codex/session_policy_test.go @@ -16,7 +16,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { for _, method := range []string{"thread/start", "thread/resume"} { t.Run(method, func(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "conv/agent/codex", DisableSubagents: true}, sessionConfig{}) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "conv/agent/codex", DisableSubagents: true, DisableExecutionEnvironment: true}, sessionConfig{}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session_run.go b/apps/daemon/internal/agent/codex/session_run.go index 9051ea3c4..7517bfb98 100644 --- a/apps/daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -47,23 +47,21 @@ func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.P ThreadID: s.currentThreadID(), Input: input, } - if plan.CollaborationMode != "" { - model := strings.TrimSpace(s.resolvedModel) - if model == "" { - return fmt.Errorf("codex: collaboration mode requires a resolved model") - } - var developerInstructions *string - if plan.SystemPrompt != "" { - developerInstructions = &plan.SystemPrompt - } - turnParams.CollaborationMode = &CollaborationMode{ - Mode: plan.CollaborationMode, - Settings: CollaborationModeSettings{ - ReasoningEffort: plan.ModelReasoningEffort, - Model: model, - DeveloperInstructions: developerInstructions, - }, - } + model := strings.TrimSpace(s.resolvedModel) + if model == "" { + return fmt.Errorf("codex: collaboration mode requires a resolved model") + } + var developerInstructions *string + if plan.SystemPrompt != "" { + developerInstructions = &plan.SystemPrompt + } + turnParams.CollaborationMode = &CollaborationMode{ + Mode: CollaborationModeDefault, + Settings: CollaborationModeSettings{ + ReasoningEffort: plan.ModelReasoningEffort, + Model: model, + DeveloperInstructions: developerInstructions, + }, } turnCtx, turnCancel := context.WithTimeout(ctx, 10*time.Second) _, ackErr := s.rpc.requestWithResult(turnCtx, "turn/start", turnParams, s.bindTurnResult) diff --git a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go index c886439de..9b2bf38f0 100644 --- a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -84,7 +84,7 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { defer cancel() out := make(chan proto.Envelope, 8) s := &Session{ - runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, + runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, resolvedModel: "synthetic", cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), cleanup: func() {}, bufs: NewItemBuffers(), interactions: newPendingCodexInteractions(), } diff --git a/apps/daemon/internal/agent/codex/session_tools.go b/apps/daemon/internal/agent/codex/session_tools.go index 5a40b45c3..d1d4b4c94 100644 --- a/apps/daemon/internal/agent/codex/session_tools.go +++ b/apps/daemon/internal/agent/codex/session_tools.go @@ -10,13 +10,11 @@ func (s *Session) sendItemEvents(events []proto.Envelope, notification json.RawM var native struct { Item json.RawMessage `json:"item"` } - if s.observeToolObservations { - if err := json.Unmarshal(notification, &native); err != nil { - return - } + if err := json.Unmarshal(notification, &native); err != nil { + return } for _, event := range events { - if (s.observeToolObservations) && event.Type == proto.TypeToolCall { + if event.Type == proto.TypeToolCall { var tool proto.ToolCallPayload if err := event.DecodePayload(&tool); err != nil { return diff --git a/apps/daemon/internal/agent/codex/session_tools_test.go b/apps/daemon/internal/agent/codex/session_tools_test.go index 2eff526bb..8d4e06c8d 100644 --- a/apps/daemon/internal/agent/codex/session_tools_test.go +++ b/apps/daemon/internal/agent/codex/session_tools_test.go @@ -21,47 +21,39 @@ func toolSnapshotFixtures() []string { } } -func TestToolObservationsOnlyWhenRequested(t *testing.T) { - for _, enabled := range []bool{false, true} { - for _, item := range toolSnapshotFixtures() { - var source struct{ ID string } - if err := json.Unmarshal([]byte(item), &source); err != nil { - t.Fatal(err) +func TestToolObservations(t *testing.T) { + for _, item := range toolSnapshotFixtures() { + var source struct{ ID string } + if err := json.Unmarshal([]byte(item), &source); err != nil { + t.Fatal(err) + } + t.Run(source.ID, func(t *testing.T) { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.setThreadID("private-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) + raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) + s.onItemStarted(raw) + s.onItemCompleted(raw) + if len(out) != 2 { + t.Fatalf("tool event count changed: %d", len(out)) } - t.Run(source.ID, func(t *testing.T) { - out := make(chan proto.Envelope, 4) - s := &Session{runID: "run", observeToolObservations: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} - s.setThreadID("private-thread") - s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) - raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) - s.onItemStarted(raw) - s.onItemCompleted(raw) - if len(out) != 2 { - t.Fatalf("tool event count changed: %d", len(out)) + for _, stage := range []string{"before", "after"} { + event := <-out + var tool proto.ToolCallPayload + if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage { + t.Fatal(event) } - for _, stage := range []string{"before", "after"} { - event := <-out - var tool proto.ToolCallPayload - if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage { - t.Fatal(event) - } - if bytes.Contains(event.Payload, []byte("native_item")) { - t.Fatal("engine-specific snapshot escaped adapter") - } - if !enabled { - if tool.Observation != nil { - t.Fatal("unrequested observation") - } - continue - } - if tool.Observation == nil || stage == "before" && tool.Observation.Status != "in_progress" { - t.Fatal(tool.Observation) - } - if source.ID == "mcp" && !bytes.Contains(tool.Observation.Output, []byte("9007199254740993")) { - t.Fatal("structured output precision lost") - } + if bytes.Contains(event.Payload, []byte("native_item")) { + t.Fatal("engine-specific snapshot escaped adapter") } - }) - } + if tool.Observation == nil || stage == "before" && tool.Observation.Status != "in_progress" { + t.Fatal(tool.Observation) + } + if source.ID == "mcp" && !bytes.Contains(tool.Observation.Output, []byte("9007199254740993")) { + t.Fatal("structured output precision lost") + } + } + }) } } diff --git a/apps/daemon/internal/agent/codex/skills.go b/apps/daemon/internal/agent/codex/skills.go index 69994f5ee..f7bdfe04a 100644 --- a/apps/daemon/internal/agent/codex/skills.go +++ b/apps/daemon/internal/agent/codex/skills.go @@ -1,24 +1,6 @@ package codex -import ( - "context" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func effectiveAgentStateKey(req proto.PromptRequestPayload) string { - if strings.TrimSpace(req.AgentStateKey) != "" { - return req.AgentStateKey - } - if id := strings.TrimSpace(req.ConversationID); id != "" { - return "_legacy_conversation/" + id + "/codex" - } - if id := strings.TrimSpace(req.RunID); id != "" { - return "_legacy_run/" + id + "/codex" - } - return "" -} +import "context" func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { if len(roots) == 0 { diff --git a/apps/daemon/internal/agent/codex/skills_test.go b/apps/daemon/internal/agent/codex/skills_test.go index 1f90c574b..bc316eed9 100644 --- a/apps/daemon/internal/agent/codex/skills_test.go +++ b/apps/daemon/internal/agent/codex/skills_test.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { @@ -40,13 +38,3 @@ func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { t.Fatalf("setSkillExtraRoots: %v", err) } } - -func TestEffectiveAgentStateKeyFallsBackToConversation(t *testing.T) { - got := effectiveAgentStateKey(proto.PromptRequestPayload{ - ConversationID: "conv-legacy", - RunID: "run-ignored", - }) - if got != "_legacy_conversation/conv-legacy/codex" { - t.Fatalf("state key = %q", got) - } -} diff --git a/apps/daemon/internal/agent/codex/subagent_profile.go b/apps/daemon/internal/agent/codex/subagent_profile.go index 6bf395c68..68afc9626 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile.go +++ b/apps/daemon/internal/agent/codex/subagent_profile.go @@ -15,16 +15,12 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay return nil } for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { - 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) } plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features." + feature, "false"}) } - plan.DisableFeatures = slices.DeleteFunc(plan.DisableFeatures, func(value string) bool { return value == "multi_agent" }) - if !slices.Contains(plan.EnableFeatures, "multi_agent") { - plan.EnableFeatures = append(plan.EnableFeatures, "multi_agent") - } + plan.EnableFeatures = append(plan.EnableFeatures, "multi_agent") plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.multi_agent", "true"}, [2]string{"agents.max_depth", "64"}) if req.MaxConcurrentSubagents != nil { if *req.MaxConcurrentSubagents < 1 { diff --git a/apps/daemon/internal/agent/codex/subagent_profile_test.go b/apps/daemon/internal/agent/codex/subagent_profile_test.go index 5e9df85a0..14ee5aa5a 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/daemon/internal/agent/codex/subagent_profile_test.go @@ -10,7 +10,7 @@ import ( func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { limit := 6 - plan := SessionPlan{EnableFeatures: []string{"hooks", "code_mode", "plugins", "multi_agent_v2"}, DisableFeatures: []string{"multi_agent"}} + plan := SessionPlan{} if err := configureSubagentObservations(&plan, proto.PromptRequestPayload{ObserveSubagentIdentities: true, MaxConcurrentSubagents: &limit}); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/subagents.go b/apps/daemon/internal/agent/codex/subagents.go index 710e12215..1c273779d 100644 --- a/apps/daemon/internal/agent/codex/subagents.go +++ b/apps/daemon/internal/agent/codex/subagents.go @@ -3,9 +3,8 @@ package codex import "slices" func disableSubagents(plan *SessionPlan) { - // Native v1 and v2 controls must override operator feature preferences. + // Disable both native Subagent feature versions. for _, feature := range []string{"multi_agent", "multi_agent_v2"} { - 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) } diff --git a/apps/daemon/internal/agent/codex/subagents_test.go b/apps/daemon/internal/agent/codex/subagents_test.go index 9e725e0cf..b70d17d7c 100644 --- a/apps/daemon/internal/agent/codex/subagents_test.go +++ b/apps/daemon/internal/agent/codex/subagents_test.go @@ -5,14 +5,10 @@ import ( "testing" ) -func TestDisableSubagentsOverridesNativeFeaturePreferences(t *testing.T) { - plan := SessionPlan{ - EnableFeatures: []string{"multi_agent", "unrelated", "multi_agent_v2"}, - DisableFeatures: []string{"another", "multi_agent"}, - } +func TestDisableSubagentsDisablesBothNativeFeatures(t *testing.T) { + plan := SessionPlan{DisableFeatures: []string{"another", "multi_agent"}} disableSubagents(&plan) - if !reflect.DeepEqual(plan.EnableFeatures, []string{"unrelated"}) || - !reflect.DeepEqual(plan.DisableFeatures, []string{"another", "multi_agent", "multi_agent_v2"}) { - t.Fatal(plan.EnableFeatures, plan.DisableFeatures) + if !reflect.DeepEqual(plan.DisableFeatures, []string{"another", "multi_agent", "multi_agent_v2"}) { + t.Fatal(plan.DisableFeatures) } } diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 1d7c41031..1f7d85bfa 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -87,12 +87,12 @@ func newView(binary string, codeModeHost bool) agent.View { ForwardEnv: slices.Clone(viewForwardEnv), Proxy: agent.ViewProxyEnv, Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilitySupported, Skills: proto.CapabilityUnsupported, FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilitySupported, ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilitySupported, }, Executor: func(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { cfg := defaultSessionConfig() @@ -129,26 +129,27 @@ func staticELF(name string) bool { } // 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. +// workspace as cwd, or the work directory with environment none, 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 !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 + cwd := path.Join(view.Home.View, agent.ViewWorkName) + if local := req.LocalEnvironment; local != nil { + cwd = local.WorkspaceRoot } - 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) - } + if (req.LocalEnvironment == nil) != req.DisableExecutionEnvironment || !path.IsAbs(cwd) { + return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace or with environment none", agent.ErrUnsupportedOperation) + } + if err := validatePermissionProfile(req); err != nil { + return SessionPlan{}, err } - servers, _, err := mcpServersFromBindings(view.MCP) + // The Harness runs each stdio alias without arguments, which the native + // configuration reports as an empty list. + servers, _, err := mcpServersFromBindings(view.MCP, func(stdio proto.EnvironmentMCP) (string, []string) { return stdio.Server.Command, []string{} }) if err != nil { return SessionPlan{}, err } @@ -161,7 +162,7 @@ func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg se return SessionPlan{}, err } disableProgrammaticTools(&plan, req.ExecutionControls) - plan.Cwd = local.WorkspaceRoot + plan.Cwd = cwd plan.Sandbox = SandboxDangerFullAcces plan.Permissions = "" plan.ApprovalPolicy = AskForApproval{String: "never"} @@ -182,7 +183,8 @@ func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg se } } // No login shell, and no ancestor walk above the workspace over the - // mount. No trust entry is written, so the project stays untrusted. + // mount. The adapter writes no trust entry; Codex keeps its native + // project trust and records its own on thread/start. plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"allow_login_shell", "false"}, [2]string{"project_root_markers", "[]"}) if req.ExecutionControls != nil { // buildSessionPlan admitted the request's provider. diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go index 326490432..a2a995f34 100644 --- a/apps/daemon/internal/agent/codex/view_test.go +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -122,7 +122,14 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} - 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) + req.LocalEnvironment, req.DisableExecutionEnvironment = nil, true + if _, err := view.Executor(t.Context(), req, session); err == nil || len(launched) != 2 { + t.Fatalf("environment none: launches %d, err %v", len(launched), err) + } + if launch := launched[1]; launch.Dir != "/.oac/home/work" || !slices.Contains(launch.Env, "CODEX_EXEC_SERVER_URL=none") { + t.Fatalf("environment none launched in %q with %v", launch.Dir, launch.Env) + } + if config, err := os.ReadFile(planted); err != nil || !strings.Contains(string(config), "command = \"/.oac/bin/oac-mcp-0\"\n\n") { + t.Fatalf("stdio alias config.toml: %v\n%s", err, config) } } diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index a919b90a1..2e6455f31 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -27,11 +27,6 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { return nil, expected }) configuration.Providers[0].Protocol = "anthropic" - copy, err := registry.Configuration("fixture") - if err != nil { - t.Fatal(err) - } - copy.Providers[0].Protocol = "anthropic" factory, _ := registry.Resolve("fixture") executor, _ := registry.ResolveExecutor("fixture") entries := []func(proto.PromptRequestPayload) error{ @@ -59,9 +54,6 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { if calls != 2 { t.Fatal("unexpected native calls", calls) } - if _, err := registry.Configuration("missing"); err == nil { - t.Fatal("undeclared configuration inferred") - } } func TestRegistryRejectsInvalidConfigurationDeclaration(t *testing.T) { diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 44f18f56b..cb7232fef 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -298,8 +298,8 @@ type ViewSession struct { MCP []MCPBinding // Launch replaces clirunner.Start. Each call builds one view and runs // Binary, which must be a LocalExec path, in it. Dir is a world path, or - // the work directory in an empty-root view; OwnProcessGroup is true, and - // Env is the complete Harness environment. + // the work directory in an empty-root view, and Env is the complete + // Harness environment. // Cancel sends TERM to every process in the view and closes the view after // KillTimeout; a Cancel after the Harness exited leaves its exit as it was. // When the Harness exits while other processes remain, the view sends them @@ -565,8 +565,8 @@ type TurnSettlement struct { Reason string } -// Session is the cancellation and outcome surface shared by direct prompt runs -// and Turns. Every owner exposes observed state, including direct-call sessions. +// Session is the cancellation and outcome surface shared by direct-call +// sessions and Turns. Every owner exposes observed state. // For Executor-owned Turns, AwaitSettlement and Executor.Close define settlement // and resource retirement; Cancel alone does not transfer resource ownership. type Session interface { @@ -645,11 +645,12 @@ type WorkspaceWriter interface { WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) } -// Direct-call factory and registration. These use the existing Registry behavior. +// Kind registration and the direct-call factory. -// Factory builds a Session for one prompt_request. out is the upstream -// channel the agent writes into and closes exactly once after terminal output. -// ctx is cancelled by the router to wind the session down. +// Factory builds a Session that runs req.Input as req.RunID without an +// Executor; the router starts every Run through RegisterExecutor instead. out +// is the channel the agent writes into and closes exactly once after terminal +// output. ctx is cancelled to wind the session down. type Factory func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (Session, error) // RegisterKind installs f and the heartbeat descriptor for an diff --git a/apps/daemon/internal/agent/installroot/probe.go b/apps/daemon/internal/agent/installroot/probe.go index b185c31bc..21f50ed47 100644 --- a/apps/daemon/internal/agent/installroot/probe.go +++ b/apps/daemon/internal/agent/installroot/probe.go @@ -14,7 +14,7 @@ import ( func Probe(parent context.Context, binary string, args, env []string, dir string) (string, error) { ctx, cancel := context.WithTimeout(parent, 25*time.Second) defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, KillTimeout: 250 * time.Millisecond}) if err != nil { return "", errors.New("native component failed to start") } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index 7f388c803..78def82a3 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -15,26 +15,24 @@ func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, []agent.MCPBi if err != nil { return nil, nil, err } - servers, err := workspaceMCP(bindings, func(binding agent.MCPBinding) (map[string]any, error) { - command, args := localworkspace.MCPStdioCommand(*binding.Stdio) - return map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}, nil - }) + servers, err := workspaceMCP(bindings, localworkspace.MCPStdioCommand) return servers, bindings, err } -// workspaceMCP renders the Session's MCP bindings as ACP servers; stdio -// renders a stdio binding. -func workspaceMCP(bindings []agent.MCPBinding, stdio func(agent.MCPBinding) (map[string]any, error)) ([]map[string]any, error) { +// workspaceMCP renders the Session's MCP bindings as ACP servers, each stdio +// binding with the command and arguments stdio gives it. +func workspaceMCP(bindings []agent.MCPBinding, stdio func(proto.EnvironmentMCP) (string, []string)) ([]map[string]any, error) { var servers []map[string]any for _, binding := range bindings { if binding.ServerLabel == "oac_workspace" || binding.ConnectionOrigin != "environment" || binding.AllowedTools != nil || binding.Required { return nil, fmt.Errorf("mcode: unsupported MCP binding") } - render := environmentHTTPMCP if binding.Transport != "http" { - render = stdio + command, args := stdio(*binding.Stdio) + servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}) + continue } - server, err := render(binding) + server, err := environmentHTTPMCP(binding) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index f8ea52093..5ca8565f9 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -98,7 +98,7 @@ func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T case "reserved": req.LocalEnvironment.MCP[0].Server.Name = "oac_workspace" } - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { + if _, err := prepareWorkspaceOptions(c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { t.Fatal("unqualified declaration accepted or credential exposed") } }) @@ -109,7 +109,6 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { c, req, _ := workspaceFixture(t) c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} - req.ObserveToolObservations = true script, err := os.ReadFile(c.Binary) if err != nil { t.Fatal(err) @@ -238,7 +237,7 @@ func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" token := "selected-public-vault-canary" req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", BearerToken: &token}} - opts, err := prepareWorkspaceOptions(t.Context(), c, req) + opts, err := prepareWorkspaceOptions(c, req) if err != nil { t.Fatal(err) } @@ -267,12 +266,12 @@ func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { } empty := []string{} (*req.MCPHTTPServers)[0].AllowedTools = &empty - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + if _, err := prepareWorkspaceOptions(c, req); err == nil { t.Fatal("empty allowlist silently treated as all") } (*req.MCPHTTPServers)[0].AllowedTools = nil (*req.MCPHTTPServers)[0].Required = true - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + if _, err := prepareWorkspaceOptions(c, req); err == nil { t.Fatal("required initialization silently ignored") } } diff --git a/apps/daemon/internal/agent/mcode/events.go b/apps/daemon/internal/agent/mcode/events.go index 0b04ce6dd..97bd1f8cd 100644 --- a/apps/daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -59,7 +59,7 @@ func (s *Session) emitTool(update toolUpdate) error { if update.ID == "" || s.completedTools[update.ID] { return nil } - previous, started := s.tools[update.ID] + previous := s.tools[update.ID] if update.Name == "" { update.Name = previous.Name } @@ -69,21 +69,18 @@ func (s *Session) emitTool(update toolUpdate) error { if update.RawInput == nil { update.RawInput = previous.RawInput } - if s.req.ObserveToolObservations { - update.mcp = previous.mcp - if update.mcp != nil && update.Name != previous.Name { - return fmt.Errorf("mcode: native MCP call identity changed") - } - if update.mcp == nil { - var err error - update.mcp, err = s.environmentMCPIdentity(update.Name) - if err != nil { - return err - } + update.mcp = previous.mcp + if update.mcp != nil && update.Name != previous.Name { + return fmt.Errorf("mcode: native MCP call identity changed") + } + if update.mcp == nil { + var err error + update.mcp, err = s.environmentMCPIdentity(update.Name) + if err != nil { + return err } - started = previous.mcp != nil || workspaceToolObservation(previous, "before") != nil } - if !started { + if previous.mcp == nil && workspaceToolObservation(previous, "before") == nil { if err := s.emitToolStage(update, "before"); err != nil { return err } diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index 2a1d36c14..7b2a93d93 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -7,7 +7,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// SupportsExecution is an operator opt-in, separate from ordinary product availability. +// SupportsExecution reports whether the daemon advertises MiniMax Code execution: +// the operator sets OAC_RUNTIME_MCODE_AGENTS_API=1 and the native version is the +// qualified one. Otherwise discovery reports only availability. func SupportsExecution(version string) bool { return os.Getenv("OAC_RUNTIME_MCODE_AGENTS_API") == "1" && version == SupportedVersion } @@ -37,12 +39,8 @@ func configureTextExecution(config map[string]any) { // Harness children use the daemon user's ordinary environment. func executionEnvironment() []string { return os.Environ() } -// ACP commands are only recognized for a single text block. Public input must -// remain user text; ordinary product Sessions retain their native command behavior. -func promptContent(text string, public bool) []map[string]string { - blocks := []map[string]string{{"type": "text", "text": text}} - if public { - blocks = append(blocks, map[string]string{"type": "text", "text": ""}) - } - return blocks +// ACP commands are only recognized for a single text block. A second, empty +// block keeps public input as user text. +func promptContent(text string) []map[string]string { + return []map[string]string{{"type": "text", "text": text}, {"type": "text", "text": ""}} } diff --git a/apps/daemon/internal/agent/mcode/execution_test.go b/apps/daemon/internal/agent/mcode/execution_test.go index a49d3e6f4..9f188f97f 100644 --- a/apps/daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -10,15 +10,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func executionRequest(t *testing.T) proto.PromptRequestPayload { - r := testRequest(t) - r.StrictResume, r.ReleaseOnCompletion, r.DisableExecutionEnvironment, r.DisableSubagents = true, true, true, true - r.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} - return r -} - func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { - r := executionRequest(t) + r := testRequest(t) t.Setenv("OAC_TEST_SECRET_CANARY", "secret") t.Setenv("NODE_OPTIONS", "--import=untrusted") opts, err := prepareOptions(r) @@ -53,7 +46,7 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "f"}} }, func(r *proto.PromptRequestPayload) { r.ExecutionControls.WebSearch = "enabled" }, } { - r := executionRequest(t) + r := testRequest(t) change(&r) if _, err := prepareOptions(r); err == nil { t.Fatal("unsupported execution accepted") @@ -63,12 +56,9 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func TestPublicTextDoesNotInvokeACPCommands(t *testing.T) { for _, text := range []string{"/model", "/compact", "hello"} { - blocks := promptContent(text, true) + blocks := promptContent(text) if len(blocks) != 2 || blocks[0]["text"] != text || blocks[1]["text"] != "" { t.Fatal(blocks) } - if blocks = promptContent(text, false); len(blocks) != 1 || blocks[0]["text"] != text { - t.Fatal("product prompt changed") - } } } diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 519a71baf..ac22cced5 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -40,25 +40,25 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { if frozen != nil { binary = frozen.Binary } - return startExecutor(ctx, req, binary, func(ctx context.Context) (launchOptions, error) { + return startExecutor(ctx, req, binary, func() (launchOptions, error) { if frozen == nil { return prepareOptions(req) } - return prepareWorkspaceOptions(ctx, *frozen, req) + return prepareWorkspaceOptions(*frozen, req) }) } } // startExecutor prepares the native owner for req, which carries no Turn // input, and starts binary. -func startExecutor(ctx context.Context, req proto.PromptRequestPayload, binary string, prepare func(context.Context) (launchOptions, error)) (agent.Executor, error) { +func startExecutor(ctx context.Context, req proto.PromptRequestPayload, binary string, prepare func() (launchOptions, error)) (agent.Executor, error) { if ctx == nil { ctx = context.Background() } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") } - opts, err := prepare(ctx) + opts, err := prepare() if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go index 637f32c4f..0daeb06e2 100644 --- a/apps/daemon/internal/agent/mcode/executor_native_test.go +++ b/apps/daemon/internal/agent/mcode/executor_native_test.go @@ -28,9 +28,8 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { if err != nil { t.Fatal("private provider options unavailable") } - req := executionRequest(t) - req.ReleaseOnCompletion = false - req.RunID, req.Input, req.ConversationID = "", nil, "" + req := testRequest(t) + req.RunID, req.Input = "", nil if json.Unmarshal(raw, &req) != nil { t.Fatal("invalid private provider options") } diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index 3c949ab0a..a7aba62ed 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -15,8 +15,8 @@ import ( func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { t.Helper() - r := executionRequest(t) - r.RunID, r.Input, r.ConversationID = "", nil, "" + r := testRequest(t) + r.RunID, r.Input = "", nil r.DisableExecutionEnvironment = false r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} record := filepath.Join(t.TempDir(), "calls") @@ -36,7 +36,6 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { t.Helper() config, req, record := workspaceFixture(t) - req.ReleaseOnCompletion = false script, err := os.ReadFile(config.Binary) if err != nil { t.Fatal(err) @@ -48,9 +47,8 @@ func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, if workspace { factory = NewExecutorFactory(&config) } else { - req = executionRequest(t) - req.ReleaseOnCompletion = false - req.RunID, req.Input, req.ConversationID = "", nil, "" + req = testRequest(t) + req.RunID, req.Input = "", nil t.Setenv("OAC_RUNTIME_MCODE_BIN", config.Binary) factory = NewExecutorFactory(nil) } @@ -283,7 +281,6 @@ func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { func TestExecutorFactoryPreparationFailureHasNoTypedNilOwner(t *testing.T) { config, req, _ := workspaceFixture(t) - req.ReleaseOnCompletion = false if err := os.WriteFile(config.Binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/executor_turn.go b/apps/daemon/internal/agent/mcode/executor_turn.go index 3bf537374..1633f5f18 100644 --- a/apps/daemon/internal/agent/mcode/executor_turn.go +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -25,7 +25,7 @@ func (s *Session) runExecutorTurn() { // Written steering requests retain their original receipt owner even after // prompt completion. No successor starts until all callers have settled. s.operations.Wait() - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady { + if !s.req.DisableSubagents && s.subagentHistoryReady { childErr := s.settleSubagents() s.mu.Lock() s.subagentSettlementError = childErr @@ -82,7 +82,7 @@ func (s *Session) runExecutorTurn() { } func (s *Session) captureSubagentBaseline() error { - if !s.req.StrictResume || s.req.DisableSubagents { + if s.req.DisableSubagents { return nil } snapshot, err := s.readSubagents(s.ctx) @@ -135,7 +135,7 @@ func (s *Session) cancelTurn(ctx context.Context) error { err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", Method: "session/cancel", Params: raw}) } if first { - if err == nil && s.req.StrictResume && !s.req.DisableSubagents { + if err == nil && !s.req.DisableSubagents { err = s.stopSubagents(ctx) } if err != nil { diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index 240c8913d..78c94b467 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -16,7 +16,7 @@ func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { t.Helper() out := make(chan proto.Envelope, 16) s := &Session{ctx: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, - req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true, + req: proto.PromptRequestPayload{RunID: "run", LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, active: true, sessionID: "native-session"} resolveTestBindings(s) diff --git a/apps/daemon/internal/agent/mcode/native_history_test.go b/apps/daemon/internal/agent/mcode/native_history_test.go index 5019a4f56..98c7c6a29 100644 --- a/apps/daemon/internal/agent/mcode/native_history_test.go +++ b/apps/daemon/internal/agent/mcode/native_history_test.go @@ -24,7 +24,7 @@ func TestNativeMCodeHistoryIsolation(t *testing.T) { } for name, id := range map[string]string{"foreign": foreign, "missing": "00000000-0000-4000-8000-000000000000"} { t.Run(name, func(t *testing.T) { - req := executionRequest(t) + req := testRequest(t) if json.Unmarshal(raw, &req) != nil { t.Fatal("invalid private options") } diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 3717a159f..cd5da0d56 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -2,7 +2,6 @@ package mcode import ( "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -52,7 +51,7 @@ func prepareOptionsWithTools(req proto.PromptRequestPayload, tools *workspaceToo if err != nil { return result, err } - if req.StrictResume && !req.DisableSubagents { + if !req.DisableSubagents { if _, _, err := subagentReader(); err != nil { return result, err } @@ -88,10 +87,8 @@ func validateOptions(req proto.PromptRequestPayload) (harnessconfig.PreparedConf if err != nil { return prepared, err } - if req.StrictResume { - if err := validateExecutionRequest(req); err != nil { - return prepared, err - } + if err := validateExecutionRequest(req); err != nil { + return prepared, err } if req.Input.HasImages() { return prepared, fmt.Errorf("mcode: ACP does not support attachments") @@ -112,15 +109,13 @@ func writeNativeConfig(req proto.PromptRequestPayload, prepared harnessconfig.Pr } config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} config["custom_provider"] = map[string]any{"oac": modelProviderConfig(prepared.Provider, prepared.Model)} - if req.StrictResume { - configureTextExecution(config) - if !req.DisableSubagents { - config["agents"] = map[string]any{"default": map[string]any{ - "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, - "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, - "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, - }} - } + configureTextExecution(config) + if !req.DisableSubagents { + config["agents"] = map[string]any{"default": map[string]any{ + "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, + "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, + "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, + }} } config["permissionMode"] = "auto" servers := map[string]any{} @@ -155,35 +150,23 @@ func writeNativeConfig(req proto.PromptRequestPayload, prepared harnessconfig.Pr if err := data.WriteFile("config.yaml", raw, 0o600); err != nil { return err } - if req.StrictResume { - if raw, err = json.Marshal(map[string]any{"mcpServers": servers}); err != nil { - return err - } - if err := data.WriteFile("mcp.json", raw, 0o600); err != nil { - return err - } + if raw, err = json.Marshal(map[string]any{"mcpServers": servers}); err != nil { + return err } - return nil + return data.WriteFile("mcp.json", raw, 0o600) } // nativeEnvironment is the adapter's own native environment, with dataDir as // the native process sees its data directory. The adapter owns the native // state location, including after cold resume. func nativeEnvironment(req proto.PromptRequestPayload, dataDir string) []string { - var env []string - if req.StrictResume { - env = append(env, "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") - if !req.DisableSubagents { - env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) - } else { - env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") - } + env := []string{"OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1"} + if !req.DisableSubagents { + env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) + } else { + env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") } - env = append(env, "MINIMAX_DATA_DIR="+dataDir) - if req.StrictResume { - env = append(env, "HOME="+dataDir, "USERPROFILE="+dataDir) - } - return env + return append(env, "MINIMAX_DATA_DIR="+dataDir, "HOME="+dataDir, "USERPROFILE="+dataDir) } // readData reads a file the native process wrote in its data directory, @@ -212,26 +195,16 @@ func dataDirectory(req proto.PromptRequestPayload) (string, error) { if err != nil { return "", fmt.Errorf("mcode: resolve data directory: %w", err) } - base := filepath.Join(root, "runtime", "mcode") - if key := strings.TrimSpace(req.AgentStateKey); key != "" { - parts := []string{base, "state"} - for _, part := range strings.Split(key, "/") { - if safe := safePathPart(part); safe != "" { - parts = append(parts, safe) - } + parts := []string{root, "runtime", "mcode", "state"} + for _, part := range strings.Split(req.AgentStateKey, "/") { + if safe := safePathPart(part); safe != "" { + parts = append(parts, safe) } - if len(parts) == 2 { - return "", fmt.Errorf("mcode: invalid agent state key %q", req.AgentStateKey) - } - return filepath.Join(parts...), nil - } - if id := safePathPart(req.ConversationID); id != "" { - return filepath.Join(base, "conv-"+id), nil } - if id := safePathPart(req.RunID); id != "" { - return filepath.Join(base, "run-"+id), nil + if len(parts) == 4 { + return "", fmt.Errorf("mcode: invalid agent state key %q", req.AgentStateKey) } - return "", errors.New("mcode: agent state key, conversation id, or run id is required") + return filepath.Join(parts...), nil } func safePathPart(value string) string { diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 9c11b545a..13099f019 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -11,6 +11,7 @@ import ( ) func TestOptionsRefreshManagedState(t *testing.T) { + t.Setenv("MINIMAX_DATA_DIR", "/wrong") req := testRequest(t) opts, err := prepareOptions(req) if err != nil { @@ -19,7 +20,13 @@ func TestOptionsRefreshManagedState(t *testing.T) { if !strings.HasPrefix(opts.Dir, os.Getenv("OAC_RUNTIME_HOME")+string(os.PathSeparator)) { t.Fatalf("workdir escaped managed state: %s", opts.Dir) } - if opts.Env[len(opts.Env)-1] != "MINIMAX_DATA_DIR="+opts.DataDir { + dataDir := "" + for _, entry := range opts.Env { + if value, ok := strings.CutPrefix(entry, "MINIMAX_DATA_DIR="); ok { + dataDir = value + } + } + if dataDir != opts.DataDir { t.Fatal("native data directory is not the adapter's") } req.SystemPrompt = "" @@ -97,11 +104,16 @@ func TestQuestionContentPreservesTypesAndValidates(t *testing.T) { } } -func TestDataDirectorySanitizesFallback(t *testing.T) { +func TestDataDirectoryRequiresAgentState(t *testing.T) { home := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", home) - got, err := dataDirectory(proto.PromptRequestPayload{ConversationID: "../conv name", RunID: "ignored"}) - if want := filepath.Join(home, "runtime", "mcode", "conv-.._conv_name"); err != nil || got != want { + for _, key := range []string{"", " ", "../.."} { + if _, err := dataDirectory(proto.PromptRequestPayload{AgentStateKey: key, RunID: "ignored"}); err == nil { + t.Fatalf("state key %q accepted", key) + } + } + got, err := dataDirectory(proto.PromptRequestPayload{AgentStateKey: "../session-1/a b"}) + if want := filepath.Join(home, "runtime", "mcode", "state", "session-1", "a_b"); err != nil || got != want { t.Fatalf("data directory = %q, %v; want %q", got, err, want) } } diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index fe6794a09..91766f1d5 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -87,7 +87,7 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti if opts.script != "" { args = []string{opts.script, "acp"} } - process, err := start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) + process, err := start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Dir: opts.Dir, Env: opts.Env, NeedStdin: true}) if err != nil { return nil, err } @@ -111,7 +111,7 @@ func (s *Session) run() { defer close(s.finished) err := s.prepareNative() if err == nil { - if s.req.StrictResume && !s.req.DisableSubagents { + if !s.req.DisableSubagents { var snapshot nativeSubagentSnapshot snapshot, err = s.readSubagents(s.ctx) s.subagentHistoryReady = err == nil @@ -128,7 +128,7 @@ func (s *Session) run() { if err == nil { err = s.executePrompt() } - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { + if !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { observationErr := s.settleSubagents() s.mu.Lock() s.subagentSettlementError = observationErr @@ -181,7 +181,7 @@ func (s *Session) prepareNative() error { if initialized.ProtocolVersion != 1 { return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) } - if s.req.StrictResume && !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { + if !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { return fmt.Errorf("mcode: native Subagent admission is unavailable") } params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} @@ -228,7 +228,7 @@ func (s *Session) executePrompt() error { var result struct { StopReason string `json:"stopReason"` } - err = s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(prompt, s.req.StrictResume)}, &result, true) + err = s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(prompt)}, &result, true) s.active = false s.mu.Lock() s.steeringReady = false @@ -367,16 +367,13 @@ func (s *Session) Cancel(ctx context.Context) error { if s.executor != nil { return s.cancelTurn(ctx) } - if s.req.StrictResume && !s.req.DisableSubagents { + if !s.req.DisableSubagents { if err := s.cancelSubagents(ctx); err != nil { s.process.Cancel() return err } } s.process.Cancel() - if !s.req.StrictResume { - return nil - } select { case <-s.exited: case <-ctx.Done(): diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index d075111a9..7e72086b8 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -19,17 +19,15 @@ import ( func testRequest(t *testing.T) proto.PromptRequestPayload { t.Helper() t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - return proto.PromptRequestPayload{RunID: "run-1", ConversationID: "conversation-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), + return proto.PromptRequestPayload{RunID: "run-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), Model: "fixture", SystemPrompt: "Current instructions", - ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example", APIKey: "fixture-key", ContextWindow: 64000, MaxOutputTokens: 4096}} + ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example", APIKey: "fixture-key", ContextWindow: 64000, MaxOutputTokens: 4096}, + DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} } func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { t.Helper() req := testRequest(t) - if scenario == "strict-cancel" { - req = executionRequest(t) - } if resume { req.AgentSessionID = "native-1" } @@ -273,7 +271,7 @@ func TestMCodeProcess(t *testing.T) { Prompt []map[string]string `json:"prompt"` } _ = json.Unmarshal(frame.Params, &input) - if strict := scenario == "strict-cancel" || (strings.HasPrefix(scenario, "prepared") || strings.HasPrefix(scenario, "executor")); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + if len(input.Prompt) != 2 { os.Exit(9) } if strings.HasPrefix(scenario, "executor") { @@ -287,7 +285,7 @@ func TestMCodeProcess(t *testing.T) { continue } update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) - update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "Read", "status": "in_progress", "rawInput": map[string]any{}}) + update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "true"}}) update("tool_call_update", map[string]any{"toolCallId": "repeated-call", "status": "completed"}) raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) @@ -303,7 +301,7 @@ func TestMCodeProcess(t *testing.T) { update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) continue } - if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { + if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "cancel-wait" { promptID = frame.ID update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) continue @@ -326,7 +324,7 @@ func TestMCodeProcess(t *testing.T) { } update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) - update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "Read", "status": "in_progress", "rawInput": map[string]any{"path": "fixture.txt"}}) + update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "cat fixture.txt"}}) for range 2 { update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) } diff --git a/apps/daemon/internal/agent/mcode/steering_test.go b/apps/daemon/internal/agent/mcode/steering_test.go index 2c4cc517a..1ecdb5af1 100644 --- a/apps/daemon/internal/agent/mcode/steering_test.go +++ b/apps/daemon/internal/agent/mcode/steering_test.go @@ -85,7 +85,7 @@ func TestTerminalFollowsAllNativeFrames(t *testing.T) { } func TestExecutionCancellationWaitsForOutputAndProcess(t *testing.T) { - s, out := helperSession(t, "strict-cancel", false) + s, out := helperSession(t, "cancel-wait", false) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() select { diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go index 557ff5dfa..ab6accd5a 100644 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -60,7 +60,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T if err != nil { t.Fatal(err) } - opts, err := prepareWorkspaceOptions(t.Context(), config, prepared) + opts, err := prepareWorkspaceOptions(config, prepared) if err != nil { t.Fatal(err) } @@ -125,7 +125,7 @@ func TestWorkspaceRejectsInvalidToolEnvironment(t *testing.T) { } t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) - if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { + if _, err := prepareWorkspaceOptions(config, req); err == nil { t.Fatal("invalid explicit tool configuration was ignored") } } diff --git a/apps/daemon/internal/agent/mcode/tool_observations.go b/apps/daemon/internal/agent/mcode/tool_observations.go index 7f6ba9f9c..1dbd6d643 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations.go +++ b/apps/daemon/internal/agent/mcode/tool_observations.go @@ -11,20 +11,18 @@ func (s *Session) emitToolStage(update toolUpdate, stage string) error { if stage == "after" { payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} } - if s.req.ObserveToolObservations { - payload.Observation = workspaceToolObservation(update, stage) - if payload.Observation == nil { - var err error - payload.Observation, err = environmentMCPObservation(update, stage) - if err != nil { - return err - } - } - // Native task/skill bookkeeping has no qualified public item mapping. - if payload.Observation == nil { - return nil + payload.Observation = workspaceToolObservation(update, stage) + if payload.Observation == nil { + var err error + payload.Observation, err = environmentMCPObservation(update, stage) + if err != nil { + return err } } + // Native task/skill bookkeeping has no qualified public item mapping. + if payload.Observation == nil { + return nil + } s.emit(proto.TypeToolCall, payload) return nil } diff --git a/apps/daemon/internal/agent/mcode/tool_observations_test.go b/apps/daemon/internal/agent/mcode/tool_observations_test.go index e9011d503..c7d1fa6da 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations_test.go +++ b/apps/daemon/internal/agent/mcode/tool_observations_test.go @@ -12,7 +12,7 @@ func TestWorkspaceCommandObservationsWaitForArgumentsAndRetainOutcome(t *testing for _, status := range []string{"completed", "failed"} { t.Run(status, func(t *testing.T) { out := make(chan proto.Envelope, 8) - s := &Session{ctx: context.Background(), req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + s := &Session{ctx: context.Background(), req: proto.PromptRequestPayload{RunID: "run"}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} s.emitTool(toolUpdate{ID: "call", Name: "mcp__oac_workspace__workspace_bash"}) if len(out) != 0 { t.Fatal("command item emitted before native arguments") diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 6e32f6b8d..f6058f1f2 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -131,12 +131,12 @@ func (i viewInstall) view() agent.View { ShimPaths: []string{"/bin/bash"}, Proxy: agent.ViewProxyNone, Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilitySupported, Skills: proto.CapabilityUnsupported, FunctionTools: proto.CapabilityUnsupported, FunctionResultImages: proto.CapabilityUnsupported, ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilitySupported, }, Executor: i.executor, } @@ -145,26 +145,30 @@ func (i viewInstall) view() agent.View { } func (i viewInstall) executor(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { - return startExecutor(ctx, req, i.node, func(ctx context.Context) (launchOptions, error) { - return i.prepare(ctx, req, session) + return startExecutor(ctx, req, i.node, func() (launchOptions, error) { + return i.prepare(req, session) }) } // prepare writes the native configuration into the Session home and renders // a closed environment. The CLI and its worker use the gateway the request -// names and the MCP in session; the request's workspace is the sandbox's. -func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (launchOptions, error) { +// names and the MCP in session. The request's workspace is the sandbox's, and +// the workspace tools present it; with environment none the CLI runs in the +// work directory without them. +func (i viewInstall) prepare(req proto.PromptRequestPayload, session agent.ViewSession) (launchOptions, error) { local := req.LocalEnvironment - if !req.StrictResume || local == nil || req.DisableExecutionEnvironment || req.WorkspaceReadOnly { - return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view runs Agents API execution in a writable Environment workspace", agent.ErrUnsupportedOperation) + if (local == nil) != req.DisableExecutionEnvironment || req.WorkspaceReadOnly { + return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view runs Agents API execution in a writable Environment workspace or with environment none", agent.ErrUnsupportedOperation) } - workspace := local.WorkspaceRoot - if !path.IsAbs(workspace) || path.Clean(workspace) != workspace || workspace == "/" { + dir := path.Join(session.Home.View, agent.ViewWorkName) + if local != nil { + dir = local.WorkspaceRoot + } + if !path.IsAbs(dir) || path.Clean(dir) != dir || dir == "/" { return launchOptions{}, errors.New("mcode: the workspace is not a canonical absolute path") } - servers, err := workspaceMCP(session.MCP, func(agent.MCPBinding) (map[string]any, error) { - return nil, fmt.Errorf("%w: a MiniMax Code view does not run stdio MCP", agent.ErrUnsupportedOperation) - }) + // The Harness runs each stdio alias without arguments. + servers, err := workspaceMCP(session.MCP, func(stdio proto.EnvironmentMCP) (string, []string) { return stdio.Server.Command, []string{} }) if err != nil { return launchOptions{}, err } @@ -191,15 +195,20 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, return launchOptions{}, err } defer data.Close() - opts := launchOptions{Dir: workspace, DataDir: filepath.Join(session.Home.Host, viewDataName), bindings: session.MCP, + opts := launchOptions{Dir: dir, DataDir: filepath.Join(session.Home.Host, viewDataName), bindings: session.MCP, start: session.Launch, script: i.cli, home: session.Home.Host} dataDir, tempDir := path.Join(session.Home.View, viewDataName), path.Join(session.Home.View, viewTempName) - tools := workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": workspace, "scratch": tempDir, "network": "enabled"}} - if err := writeNativeConfig(private, prepared, data, dataDir, &tools); err != nil { + var tools *workspaceTools + opts.MCP = []map[string]any{} + if local != nil { + tools = &workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": dir, "scratch": tempDir, "network": "enabled"}} + opts.MCP = append(opts.MCP, tools.server(dataDir)) + } + if err := writeNativeConfig(private, prepared, data, dataDir, tools); err != nil { return opts, err } opts.Model = prepared.Model - opts.MCP = append([]map[string]any{tools.server(dataDir)}, servers...) + opts.MCP = append(opts.MCP, servers...) opts.Env = []string{ "PATH=" + path.Join(agent.ViewPrivateRoot, agent.ViewShimName), "TMPDIR=" + tempDir, @@ -213,6 +222,6 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, } opts.Env = append(opts.Env, nativeEnvironment(private, dataDir)...) opts.spawn = session.Spawn - opts.reader = clirunner.StartOptions{Binary: i.node, Args: []string{path.Join(path.Dir(i.bridge), "subagent-snapshot.mjs"), dataDir}, Dir: dataDir, Env: opts.Env, OwnProcessGroup: true} + opts.reader = clirunner.StartOptions{Binary: i.node, Args: []string{path.Join(path.Dir(i.bridge), "subagent-snapshot.mjs"), dataDir}, Dir: dataDir, Env: opts.Env} return opts, nil } diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go index 263b8f25c..7b6d3a953 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -55,8 +56,8 @@ func viewFixture(t *testing.T) (viewInstall, agent.View, proto.PromptRequestPayl t.Setenv("OAC_TEST_VIEW_SENTINEL", "daemon-only") t.Setenv("ANTHROPIC_API_KEY", viewRealKey) - req := executionRequest(t) - req.RunID, req.Input, req.ConversationID = "", nil, "" + req := testRequest(t) + req.RunID, req.Input = "", nil req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: "/workspace", NetworkAccess: "enabled"} req.ModelProvider = &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "http://127.0.0.1:4101", APIKey: modelprovider.Placeholder, ContextWindow: 64000, MaxOutputTokens: 4096} @@ -119,6 +120,28 @@ func TestViewLaunchesNodeWithGatewayOnly(t *testing.T) { } } +// With environment none the CLI runs in the work directory without the +// workspace tools, and it runs each stdio binding's alias without arguments. +func TestViewRunsEnvironmentNoneAndStdioAliases(t *testing.T) { + install, _, req := viewFixture(t) + session := agent.ViewSession{Home: agent.ViewDir{Host: t.TempDir(), View: path.Join(agent.ViewPrivateRoot, agent.ViewHomeName)}} + none := req + none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true + opts, err := install.prepare(none, session) + if err != nil || opts.Dir != "/.oac/home/work" || opts.MCP == nil || len(opts.MCP) != 0 { + t.Fatalf("environment none runs in %q with MCP %v: %v", opts.Dir, opts.MCP, err) + } + + session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ + Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} + if opts, err = install.prepare(req, session); err != nil || len(opts.MCP) != 2 || opts.MCP[0]["name"] != "oac_workspace" || opts.MCP[1]["command"] != agent.ViewAlias(0) { + t.Fatalf("stdio MCP = %v: %v", opts.MCP, err) + } + if args, ok := opts.MCP[1]["args"].([]string); !ok || args == nil || len(args) != 0 { + t.Fatalf("the stdio alias runs with arguments %#v", opts.MCP[1]["args"]) + } +} + func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { install, _, req := viewFixture(t) req.DisableSubagents, req.MaxConcurrentSubagents = false, new(2) @@ -129,7 +152,7 @@ func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { spawned = options return clirunner.Start(clirunner.StartOptions{Parent: options.Parent, Binary: "/bin/echo", Args: []string{`{"version":1,"complete":true,"rootSessionId":"root"}`}}) } - opts, err := install.prepare(t.Context(), req, session) + opts, err := install.prepare(req, session) if err != nil { t.Fatal(err) } @@ -139,7 +162,7 @@ func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { } data := path.Join(session.Home.View, viewDataName) args := []string{"--disable-warning=ExperimentalWarning", path.Join(path.Dir(install.bridge), "subagent-snapshot.mjs"), data, "root"} - if spawned.Binary != install.node || !slices.Equal(spawned.Args, args) || spawned.Dir != data || !spawned.OwnProcessGroup || !slices.Contains(spawned.Env, "LD_LIBRARY_PATH="+install.loader.LibraryPath) { + if spawned.Binary != install.node || !slices.Equal(spawned.Args, args) || spawned.Dir != data || !slices.Contains(spawned.Env, "LD_LIBRARY_PATH="+install.loader.LibraryPath) { t.Fatalf("spawned %+v", spawned) } } diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index 459302897..61bcb719a 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -1,7 +1,6 @@ package mcode import ( - "context" "fmt" "os" "path/filepath" @@ -49,11 +48,11 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn return c, nil } -func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { +func prepareWorkspaceOptions(c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { if c.Network != "enabled" || len(c.AllowedDomains) != 0 { return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") } - if !req.StrictResume || req.LocalEnvironment == nil || req.LocalEnvironment.WorkspaceRoot != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { + if req.LocalEnvironment == nil || req.LocalEnvironment.WorkspaceRoot != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } servers, bindings, err := runtimeMCP(req) diff --git a/apps/daemon/internal/agent/mcode/workspace_network_test.go b/apps/daemon/internal/agent/mcode/workspace_network_test.go index a42d387cd..83b3156ee 100644 --- a/apps/daemon/internal/agent/mcode/workspace_network_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_network_test.go @@ -7,7 +7,7 @@ func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { c, req, _ := workspaceFixture(t) c.Network = access req.LocalEnvironment.NetworkAccess = access - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + if _, err := prepareWorkspaceOptions(c, req); err == nil { t.Fatal("unsupported network isolation accepted") } } diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness.go b/apps/daemon/internal/agent/mcode/workspace_readiness.go index b730745b4..7f44d672a 100644 --- a/apps/daemon/internal/agent/mcode/workspace_readiness.go +++ b/apps/daemon/internal/agent/mcode/workspace_readiness.go @@ -16,7 +16,7 @@ import ( func CheckWorkspace(ctx context.Context, c WorkspaceConfig) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(filepath.Dir(c.Bridge), "check.mjs")}, Env: executionEnvironment(), OwnProcessGroup: true}) + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(filepath.Dir(c.Bridge), "check.mjs")}, Env: executionEnvironment()}) if err != nil { return err } diff --git a/apps/daemon/internal/agent/mcode/workspace_skills_test.go b/apps/daemon/internal/agent/mcode/workspace_skills_test.go index ed89e029f..0d7c9470d 100644 --- a/apps/daemon/internal/agent/mcode/workspace_skills_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_skills_test.go @@ -28,7 +28,7 @@ func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { Metadata: agentskill.Metadata{Name: "proof", Description: "Read a marker"}, }} for range 2 { - opts, err := prepareWorkspaceOptions(t.Context(), c, req) + opts, err := prepareWorkspaceOptions(c, req) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index cb1ab18b1..5dec68997 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -105,8 +105,6 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env return nil, unsupported("environment none") case local != nil && !isViewPath(local.WorkspaceDirectory): return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) - case !req.StrictResume: - return nil, unsupported("a Session without strict resume") case local != nil && local.Capabilities && local.CapabilityRoot == "": return nil, unsupported("installed Capabilities that no preparation resolved") case local != nil && len(local.Skills) > 0 && !caps.Skills.IsSupported(): diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index a6d6f7d3b..b9bda343a 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -85,7 +85,6 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "relative workspace": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.WorkspaceDirectory = "workspace" }, []error{ErrInvalidSession}}, "no model provider": {"viewed", func(r *proto.PromptRequestPayload) { r.ModelProvider = nil }, []error{ErrUnsupported}}, // The typed rejections that hold whatever the view declares. - "no strict resume": {"supporting", func(r *proto.PromptRequestPayload) { r.StrictResume = false }, unsupported}, "restricted network": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, unsupported}, "allowed domains only": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, unsupported}, "unprepared Capabilities": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, unsupported}, diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 7316189c3..09655db85 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -89,7 +89,6 @@ func declared(s proto.CapabilitySupport) agent.ViewCapabilities { func request(kind, workspace, baseURL, key string) proto.PromptRequestPayload { return proto.PromptRequestPayload{ AgentKind: kind, - StrictResume: true, Model: "m", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: baseURL, APIKey: key}, LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}, diff --git a/apps/daemon/internal/agenthost/launch_linux.go b/apps/daemon/internal/agenthost/launch_linux.go index 3fde5e108..08456e20c 100644 --- a/apps/daemon/internal/agenthost/launch_linux.go +++ b/apps/daemon/internal/agenthost/launch_linux.go @@ -73,8 +73,6 @@ func (s *session) checkStart(opts clirunner.StartOptions) error { return fmt.Errorf("%w: %w: %q", ErrLaunch, agent.ErrNotLocalExec, opts.Binary) case !isViewPath(opts.Dir): return fmt.Errorf("%w: directory %q is not absolute and clean", ErrLaunch, opts.Dir) - case !opts.OwnProcessGroup: - return fmt.Errorf("%w: a view process runs in its own process group", ErrLaunch) } return nil } diff --git a/apps/daemon/internal/agenthost/session_linux_test.go b/apps/daemon/internal/agenthost/session_linux_test.go index 19ca04298..f705fcdc9 100644 --- a/apps/daemon/internal/agenthost/session_linux_test.go +++ b/apps/daemon/internal/agenthost/session_linux_test.go @@ -145,7 +145,7 @@ func TestSpawnKeepsItsErrors(t *testing.T) { s, _ := newOwnerSession(t) s.plan = &plan{view: agent.View{LocalExec: []string{"/bin/true"}}} s.live = &liveView{view: &fakeView{exit: make(chan struct{}), spawnErr: c.err}} - _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/true", Dir: "/", OwnProcessGroup: true}) + _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/true", Dir: "/"}) if !errors.Is(err, c.err) || errors.Is(err, agent.ErrNoLiveView) != c.ended { t.Errorf("Spawn failing with %v = %v; want that error, and ErrNoLiveView only for an ended view", c.err, err) } diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index f4bac56cb..f99b145d3 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -159,10 +159,10 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { } // The Turn waited for its Harness, so no view runs. s := d.session(b) - if _, err := s.spawn(clirunner.StartOptions{Binary: harnessPath, Dir: workspace, OwnProcessGroup: true}); !errors.Is(err, agent.ErrNoLiveView) { + if _, err := s.spawn(clirunner.StartOptions{Binary: harnessPath, Dir: workspace}); !errors.Is(err, agent.ErrNoLiveView) { t.Errorf("Spawn after the Harness exited = %v, want ErrNoLiveView", err) } - if _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/sh", Dir: workspace, OwnProcessGroup: true}); !errors.Is(err, agent.ErrNotLocalExec) { + if _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/sh", Dir: workspace}); !errors.Is(err, agent.ErrNotLocalExec) { t.Errorf("Spawn of a binary outside LocalExec = %v, want ErrNotLocalExec", err) } select { @@ -266,7 +266,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { until(t, "the Harness to run", beating) // A Subagent runs in the live view. p, err := d.session(waiting).spawn(clirunner.StartOptions{Binary: harnessPath, Args: []string{"touch"}, Dir: workspace, - Env: []string{harnessEnv + "=1"}, OwnProcessGroup: true}) + Env: []string{harnessEnv + "=1"}}) if err != nil { t.Fatalf("Spawn in the live view: %v", err) } @@ -667,7 +667,7 @@ type testExecutor struct { func (e *testExecutor) StartTurn(_ context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { mode := *input[0].Content[0].Text p, err := e.session.Launch(clirunner.StartOptions{Binary: harnessPath, Args: []string{mode}, Dir: e.dir, Env: e.env, - OwnProcessGroup: true, KillTimeout: time.Second}) + KillTimeout: time.Second}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 5478627f1..f7e4b915d 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -19,6 +19,7 @@ import ( "image" "image/png" "io" + "io/fs" "log/slog" "math/big" "net/http/httptest" @@ -39,6 +40,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview/sessionviewtest" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" @@ -122,7 +124,11 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { // the value and the status. A view that declares function tools runs a second // Turn in a new Executor, which resumes the Session's native history, and // calls a function there. A view that declares tool search runs a Turn in -// another Session that finds the function, deferred, with tool search. +// another Session that finds the function, deferred, with tool search. A view +// that declares environment none answers a Turn in a Session without an +// Environment, and its native state names the work directory. +// A view that declares stdio MCP calls a tool of a stdio MCP server that runs +// in the sandbox. func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps agent.ViewCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) @@ -138,7 +144,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, } - configuration := proto.PromptRequestPayload{AgentKind: kind, StrictResume: true, DisableSubagents: true, + configuration := proto.PromptRequestPayload{AgentKind: kind, DisableSubagents: true, Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}} if caps.FunctionTools.IsSupported() { @@ -175,8 +181,51 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, done, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) k.check(t, done, calls) } + if caps.EnvironmentNone.IsSupported() { + none := configuration + none.LocalEnvironment, none.DisableExecutionEnvironment, none.FunctionTools, none.ToolSearch = nil, true, nil, false + s := sb.session(h, cfg, agenthost.Environment{}, none) + done, _ := s.turn(t, "environment-none", "What is 17 times 23? Answer with exactly one line: PRODUCT=", k) + if !strings.Contains(done.Content, "PRODUCT=391") { + t.Errorf("the answer %q does not report PRODUCT=391", done.Content) + } + s.checkCwd(t, agent.ViewPrivateRoot+"/"+agent.ViewHomeName+"/"+agent.ViewWorkName) + } + if caps.StdioMCP.IsSupported() { + code := strings.ToLower(rand.Text()[:12]) + stdio := configuration + stdio.FunctionTools, stdio.ToolSearch = nil, false + s := sb.session(h, cfg, env, stdio) + s.mcp = []proto.EnvironmentMCP{{InstallationRoot: workspace, Server: agentplugin.MCPServer{Name: "qualify", Type: "stdio", Command: "python3", Args: []string{"-c", mcpServer, code}}}} + done, _ := s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) + if !strings.Contains(done.Content, "CODE="+code) { + t.Errorf("the answer %q does not report CODE=%s", done.Content, code) + } + } } +// mcpServer is a stdio MCP server whose one tool returns the code in its +// argument. +const mcpServer = `import json, sys +code = sys.argv[1] +for line in iter(sys.stdin.readline, ""): + msg = json.loads(line) if line.strip() else {} + if "id" not in msg or "method" not in msg: + continue + method, reply = msg["method"], {"jsonrpc": "2.0", "id": msg["id"]} + if method == "initialize": + reply["result"] = {"protocolVersion": msg["params"]["protocolVersion"], "capabilities": {"tools": {}}, "serverInfo": {"name": "qualify", "version": "1"}} + elif method == "tools/list": + reply["result"] = {"tools": [{"name": "reveal_code", "description": "Returns the qualification code.", "inputSchema": {"type": "object", "properties": {}}}]} + elif method == "tools/call": + reply["result"] = {"content": [{"type": "text", "text": "The code is " + code + "."}]} + elif method == "ping": + reply["result"] = {} + else: + reply["error"] = {"code": -32601, "message": "method not found"} + print(json.dumps(reply), flush=True) +` + // lookupTicket is the function the function Turns call. var lookupTicket = proto.FunctionTool{Name: "lookup_ticket", Description: "Looks up a support ticket by its number.", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string","description":"The ticket number"}},"required":["ticket"],"additionalProperties":false}`)} @@ -227,6 +276,9 @@ type session struct { env agenthost.Environment id string configuration proto.PromptRequestPayload + // mcp is the installed MCP that the Environment's preparation resolves + // into each request; the wire does not carry it. + mcp []proto.EnvironmentMCP } func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthost.Environment, configuration proto.PromptRequestPayload) *session { @@ -242,10 +294,13 @@ func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthos func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { t.Helper() out := make(sender, 256) - router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, - Registry: s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { - return s.binding, s.env, nil - })}) + reg := s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { + return s.binding, s.env, nil + }) + if s.mcp != nil { + reg = withMCP(t, reg, s.mcp) + } + router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, Registry: reg}) if err != nil { t.Fatal(err) } @@ -266,6 +321,51 @@ func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePa return done, calls } +// withMCP wraps reg so that each request's Environment carries mcp. +func withMCP(t *testing.T, reg *agent.Registry, mcp []proto.EnvironmentMCP) *agent.Registry { + wrapped := agent.NewRegistry() + for _, info := range reg.SupportedAgentKinds() { + configuration, err := reg.Configuration(info.Kind) + direct, directErr := reg.Resolve(info.Kind) + factory, factoryErr := reg.ResolveExecutor(info.Kind) + if err := errors.Join(err, directErr, factoryErr); err != nil { + t.Fatal(err) + } + wrapped.RegisterKind(info, configuration, direct) + wrapped.RegisterExecutor(info.Kind, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + local := *req.LocalEnvironment + local.MCP = mcp + req.LocalEnvironment = &local + return factory(ctx, req) + }) + } + return wrapped +} + +// checkCwd checks that the Harness's native state in the Session home names +// cwd, which the adapter writes into none of its files there. +func (s *session) checkCwd(t *testing.T, cwd string) { + t.Helper() + want := []byte(cwd) + home := filepath.Join(s.cfg.StateDir, "sessions", s.binding.SessionID.String(), agent.ViewHomeName) + var found string + err := filepath.WalkDir(home, func(name string, entry fs.DirEntry, err error) error { + if err != nil || found != "" || !entry.Type().IsRegular() { + return err + } + if body, err := os.ReadFile(name); err != nil || bytes.Contains(body, want) { + found = name + return err + } + return nil + }) + if err != nil || found == "" { + t.Errorf("no native state in %s names %s: %v", home, want, err) + return + } + t.Logf("%s names %s", found, want) +} + func handle(t *testing.T, router *dispatch.Router, typ, id string, payload any) { t.Helper() e, err := proto.NewEnvelope(typ, id, payload) diff --git a/apps/daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go index 0bef9bbf6..ec636f67e 100644 --- a/apps/daemon/internal/auth/store.go +++ b/apps/daemon/internal/auth/store.go @@ -1,6 +1,7 @@ -// Package auth reads the operator device profile that oac-core-device prints -// into ~/.oac/daemon//auth.json: the server URL, the device ID and -// its runner credential. +// Package auth reads the daemon credential profile written by +// oac-core-device: server URL, runtime row id (= device_id), and the +// long-lived runner_credential. Stored as JSON per-profile at +// ~/.oac/daemon//auth.json (0o600). package auth import ( @@ -12,15 +13,15 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) -// Profile is the on-disk representation of one device profile. +// Profile is the on-disk representation of one daemon credential. type Profile struct { // ServerURL is the absolute base URL the daemon dials (no // trailing slash). The daemon joins this with paths like // /agent-daemon/bootstrap. ServerURL string `json:"server_url"` - // RuntimeID is the device ID. The gateway uses it verbatim as - // device_id on WS upgrade. + // RuntimeID is the runtimes row id. The gateway uses it verbatim + // as device_id on WS upgrade. RuntimeID string `json:"runtime_id"` // RunnerCredential is the bearer presented on every @@ -32,11 +33,11 @@ type Profile struct { DeviceName string `json:"device_name,omitempty"` } -// ErrNoProfile is returned by Load when no auth.json exists for the +// ErrNotPaired is returned by Load when no auth.json exists for the // requested profile. -var ErrNoProfile = errors.New("auth: no device profile — provision one with oac-core-device") +var ErrNotPaired = errors.New("auth: no daemon credential profile — create one with oac-core-device") -// Load reads the profile's auth.json. Returns ErrNoProfile wrapping +// Load reads the profile's auth.json. Returns ErrNotPaired wrapping // fs.ErrNotExist when the file is missing. func Load(profile string) (Profile, error) { authPath, err := paths.AuthFile(profile) @@ -46,9 +47,9 @@ func Load(profile string) (Profile, error) { raw, err := os.ReadFile(authPath) if err != nil { if errors.Is(err, os.ErrNotExist) { - // Multi-%w so errors.Is matches both ErrNoProfile AND + // Multi-%w so errors.Is matches both ErrNotPaired AND // fs.ErrNotExist. - return Profile{}, fmt.Errorf("%w (looked at %s): %w", ErrNoProfile, authPath, err) + return Profile{}, fmt.Errorf("%w (looked at %s): %w", ErrNotPaired, authPath, err) } return Profile{}, fmt.Errorf("auth: read: %w", err) } diff --git a/apps/daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go index bc764daff..1d3bfffcc 100644 --- a/apps/daemon/internal/auth/store_test.go +++ b/apps/daemon/internal/auth/store_test.go @@ -1,6 +1,7 @@ package auth_test import ( + "encoding/json" "errors" "io/fs" "os" @@ -9,57 +10,117 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) -func writeProfile(t *testing.T, raw string) { +func withTempHome(t *testing.T) string { t.Helper() - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - path, err := paths.AuthFile("default") + dir := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", dir) + return dir +} + +func profileDir(t *testing.T, profile string) string { + t.Helper() + dir, err := paths.ProfileDir(profile) + if err != nil { + t.Fatalf("ProfileDir: %v", err) + } + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + t.Fatalf("EnsurePrivateDir: %v", err) + } + return dir +} + +func writeProfile(t *testing.T, profile string, p auth.Profile) { + t.Helper() + dir := profileDir(t, profile) + raw, err := json.Marshal(p) if err != nil { - t.Fatal(err) + t.Fatalf("marshal profile: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.json"), raw, 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } +} + +func TestLoadReadsProfile(t *testing.T) { + _ = withTempHome(t) + want := auth.Profile{ + ServerURL: "https://core.example.com", + RuntimeID: "rt_abc123", + RunnerCredential: "secret-credential", + DeviceName: "alice-mac", } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) + writeProfile(t, "test", want) + got, err := auth.Load("test") + if err != nil { + t.Fatalf("Load: %v", err) } - if raw != "" { - if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { - t.Fatal(err) - } + if got != want { + t.Fatalf("Load mismatch:\n got=%+v\nwant=%+v", got, want) } } -func TestLoadReadsTheDeviceProfile(t *testing.T) { - writeProfile(t, `{"server_url":"https://core.example.com/api/v1","runtime_id":"rt_abc123","runner_credential":"secret-credential","device_name":"engine host"}`) - got, err := auth.Load("default") - want := auth.Profile{ServerURL: "https://core.example.com/api/v1", RuntimeID: "rt_abc123", RunnerCredential: "secret-credential", DeviceName: "engine host"} - if err != nil || got != want { - t.Fatalf("Load = %+v, %v; want %+v", got, err, want) +func TestLoadIgnoresLegacyProfileFields(t *testing.T) { + _ = withTempHome(t) + raw := `{"server_url":"https://core.example.com/api/v1","runtime_id":"rt","runner_credential":"c","device_name":"d",` + + `"hostname":"h","paired_at":"2026-06-04T12:00:00Z","runner_public_key":"pub","runner_private_key":"priv"}` + if err := os.WriteFile(filepath.Join(profileDir(t, "legacy"), "auth.json"), []byte(raw), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } + got, err := auth.Load("legacy") + if err != nil { + t.Fatalf("Load: %v", err) + } + want := auth.Profile{ServerURL: "https://core.example.com/api/v1", RuntimeID: "rt", RunnerCredential: "c", DeviceName: "d"} + if got != want { + t.Fatalf("Load = %+v, want %+v", got, want) } } -func TestLoadMissingReturnsErrNoProfile(t *testing.T) { - writeProfile(t, "") +func TestLoadMissingReturnsErrNotPaired(t *testing.T) { + _ = withTempHome(t) _, err := auth.Load("default") - if !errors.Is(err, auth.ErrNoProfile) || !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("Load on missing profile returned %v, want ErrNoProfile wrapping fs.ErrNotExist", err) + if !errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load on missing profile returned %v, want ErrNotPaired", err) + } + // ErrNotPaired must also wrap fs.ErrNotExist for the canonical + // "missing file" check. + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("ErrNotPaired must wrap fs.ErrNotExist, got %v", err) } } func TestLoadCorruptJSONReturnsError(t *testing.T) { - writeProfile(t, "{not valid json") - if _, err := auth.Load("default"); err == nil || errors.Is(err, auth.ErrNoProfile) { - t.Fatalf("Load on corrupt JSON = %v, want a parse error", err) + _ = withTempHome(t) + dir := profileDir(t, "default") + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte("{not valid json"), 0o600); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + _, err := auth.Load("default") + if err == nil { + t.Fatal("Load returned nil error on corrupt JSON") + } + if errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load on corrupt JSON should not be ErrNotPaired: %v", err) } } func TestDeleteIsIdempotent(t *testing.T) { - writeProfile(t, `{"server_url":"https://x","runtime_id":"rt","runner_credential":"c"}`) - for range 2 { - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete: %v", err) - } - } - if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNoProfile) { - t.Fatalf("Load after Delete = %v, want ErrNoProfile", err) + _ = withTempHome(t) + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err) + } + writeProfile(t, "default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}) + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load after Delete = %v, want ErrNotPaired", err) + } + // Second Delete must still succeed. + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete second call = %v, want nil", err) } } diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go index 78217d94b..19741a255 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -89,7 +89,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) defer cancel() id := uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: prototest.StateKey, AgentSessionID: resume, RequireExistingNativeSession: resume != "", StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3"} + request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: prototest.StateKey, AgentSessionID: resume, RequireExistingNativeSession: resume != "", ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3"} if callFunction { request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} } diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index 71c8fe2e5..f920318b3 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -30,9 +30,12 @@ const ( // runConnect dials /agent-daemon/bootstrap, opens /agent-daemon/ws, // wires the dispatch router, and routes Envelope traffic both ways -// until either SIGINT/SIGTERM or a permanent credential rejection. It -// authenticates with a Runtime bootstrap file, self-hosted Environment -// enrollment or the profile's operator device profile. +// until either SIGINT/SIGTERM or a permanent credential rejection. +// +// The daemon credential comes from a Provider bootstrap file +// (--bootstrap-file), self-hosted Environment enrollment +// (--remote/--environment-id/--credential-file) or the saved profile +// written by oac-core-device. // // -b re-execs the binary in the background with stdio redirected to // connect.log and the child PID written to connect.pid. The child @@ -40,7 +43,7 @@ const ( func runConnect(ctx *runContext, args []string) error { fs := newFlagSet("connect") var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name for the device profile and pid/log files") + profile = fs.String("profile", paths.DefaultProfile, "profile name for daemon credentials and pid/log files") background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") @@ -74,7 +77,7 @@ func runConnect(ctx *runContext, args []string) error { } if *remote != "" || *environment != "" || *credentialFile != "" { if fs.NArg() != 0 { - return errors.New("connect: Environment enrollment takes no positional arguments") + return errors.New("connect: Environment enrollment cannot use positional arguments") } connectCtx, stop := daemonize.NotifyContext(context.Background()) defer stop() @@ -92,11 +95,11 @@ func runConnect(ctx *runContext, args []string) error { return fmt.Errorf("connect: %w", err) } } - return spawnBackground(context.Background(), ctx, *profile) + return spawnBackground(context.Background(), ctx, *profile, os.Args) } // Self-check before loading credentials so a machine with no - // supported agent CLI fails before it connects. + // supported agent CLI fails fast. agentCLIs, err := preflightAgentCLIs(context.Background(), ctx, *profile) if err != nil { return err @@ -105,8 +108,11 @@ func runConnect(ctx *runContext, args []string) error { var prof auth.Profile if bootstrapped != nil { prof = *bootstrapped - } else if prof, err = auth.Load(*profile); err != nil { - return fmt.Errorf("connect: %w", err) + } else { + prof, err = auth.Load(*profile) + if err != nil { + return fmt.Errorf("connect: %w", err) + } } return mainLoop(ctx, *profile, prof, agentCLIs) @@ -116,7 +122,7 @@ func runConnect(ctx *runContext, args []string) error { // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either // mode. -func spawnBackground(ctx context.Context, rc *runContext, profile string) error { +func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string) error { logPath, err := paths.LogFile(profile) if err != nil { return fmt.Errorf("connect: %w", err) @@ -155,7 +161,10 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string) error if err := ctx.Err(); err != nil { return err } - pid, err := daemonize.Spawn(os.Args, daemonize.ReExecOptions{LogPath: logPath, PIDPath: pidPath}) + pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + }) if err != nil { return fmt.Errorf("connect: spawn background: %w", err) } @@ -273,7 +282,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof return nil } if errors.Is(err, transport.ErrPermanent) { - return fmt.Errorf("connect: permanent error (re-pair the daemon): %w", err) + return fmt.Errorf("connect: permanent error (reissue the daemon credential): %w", err) } return fmt.Errorf("connect: dial: %w", err) } @@ -298,7 +307,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof // Permanent error (e.g. runtime deleted) → exit instead of // reconnecting. if pumpErr != nil && errors.Is(pumpErr, transport.ErrPermanent) { - return fmt.Errorf("connect: runtime deleted (re-pair the daemon): %w", pumpErr) + return fmt.Errorf("connect: runtime deleted (reissue the daemon credential): %w", pumpErr) } // Small breather before redialing so a flapping server doesn't // get a tight loop of upgrade requests. diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go index 4092479dc..1e7848808 100644 --- a/apps/daemon/internal/cli/connect_bootstrap.go +++ b/apps/daemon/internal/cli/connect_bootstrap.go @@ -8,7 +8,7 @@ import ( ) // The launch file is the sole credential source for this connection. Reopening -// it on process restart neither pairs again nor overwrites an auth profile. +// it on process restart never reads or overwrites an auth profile. func bootstrapProfile(path string) (*auth.Profile, error) { raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes) if err != nil { diff --git a/apps/daemon/internal/cli/connect_bootstrap_test.go b/apps/daemon/internal/cli/connect_bootstrap_test.go index be7cfad32..7f510d699 100644 --- a/apps/daemon/internal/cli/connect_bootstrap_test.go +++ b/apps/daemon/internal/cli/connect_bootstrap_test.go @@ -1,28 +1,34 @@ package cli import ( - "bytes" + "encoding/json" "io" "os" "path/filepath" "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - profile, err := paths.AuthFile("default") + prior := auth.Profile{ServerURL: "https://other.example/api/v1", RuntimeID: "retained", RunnerCredential: "retained-secret"} + profileDir, err := paths.ProfileDir("default") if err != nil { t.Fatal(err) } - prior := []byte(`{"server_url":"https://other.example/api/v1","runtime_id":"retained","runner_credential":"retained-secret"}`) - if err = os.MkdirAll(filepath.Dir(profile), 0o700); err != nil { + if err = runtimefs.EnsurePrivateDir(profileDir); err != nil { t.Fatal(err) } - if err = os.WriteFile(profile, prior, 0o600); err != nil { + priorRaw, err := json.Marshal(prior) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(profileDir, "auth.json"), priorRaw, 0600); err != nil { t.Fatal(err) } input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret"} @@ -40,8 +46,8 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { t.Fatal("failed bootstrap/restart", err) } } - got, err := os.ReadFile(profile) - if err != nil || !bytes.Equal(got, prior) { + got, err := auth.Load("default") + if err != nil || got != prior { t.Fatal("private profile modified", err) } if err = os.WriteFile(path, []byte("bootstrap-secret"), 0600); err != nil { diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index c4658752d..e7d44e052 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -150,7 +150,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { t.Fatalf("bind = %+v", got) } env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", - Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", StrictResume: true, DisableExecutionEnvironment: true})}) + Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", DisableExecutionEnvironment: true})}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go index 6eea718d7..0b1c3b4b1 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" @@ -160,7 +161,7 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(parent, rc, profile) + return spawnBackground(parent, rc, profile, os.Args) } // Discovery consumes the immutable Runtime binding; it must follow enrollment. discovery, err := preflightAgentCLIs(parent, rc, profile) diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 28999d0be..cd77a7d2b 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -41,7 +41,7 @@ var commands = []command{ {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Open the reverse WebSocket and start serving prompts", run: runConnect}, - {name: "status", summary: "Print the device profile and daemon state", run: runStatus}, + {name: "status", summary: "Print the credential profile and daemon state", run: runStatus}, {name: "stop", summary: "Stop a background `connect -b` daemon", run: runStop}, {name: "logs", summary: "Tail the background daemon's log file", run: runLogs}, {name: "logout", summary: "Forget the credential for a profile", run: runLogout}, diff --git a/apps/daemon/internal/cli/status.go b/apps/daemon/internal/cli/status.go index 319f62f5b..beb9e444d 100644 --- a/apps/daemon/internal/cli/status.go +++ b/apps/daemon/internal/cli/status.go @@ -31,12 +31,12 @@ func runStatus(ctx *runContext, args []string) error { prof, err := auth.Load(*profile) switch { - case errors.Is(err, auth.ErrNoProfile): - fmt.Fprintln(ctx.stdout, "device : no device profile; check Host connection in Core for a self-hosted Runtime") + case errors.Is(err, auth.ErrNotPaired): + fmt.Fprintln(ctx.stdout, "paired : no saved credential profile; check Host connection in Core for a self-hosted Runtime") case err != nil: - fmt.Fprintf(ctx.stdout, "device : ERROR — %v\n", err) + fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) default: - fmt.Fprintln(ctx.stdout, "device : profile present") + fmt.Fprintln(ctx.stdout, "paired : yes") fmt.Fprintf(ctx.stdout, "server_url : %s\n", prof.ServerURL) fmt.Fprintf(ctx.stdout, "runtime_id : %s\n", prof.RuntimeID) if prof.DeviceName != "" { @@ -46,7 +46,7 @@ func runStatus(ctx *runContext, args []string) error { // connect.pid existence is the cheap signal; the full liveness // check (kill -0) would be more accurate but a bare existence - // check is honest enough for the "provisioned but not connected" + // check is honest enough for the "paired but not connected" // diagnosis. pidPath, err := paths.PIDFile(*profile) if err != nil { diff --git a/apps/daemon/internal/daemonize/fork_test.go b/apps/daemon/internal/daemonize/fork_test.go index 7693a4918..a8f4fc5a6 100644 --- a/apps/daemon/internal/daemonize/fork_test.go +++ b/apps/daemon/internal/daemonize/fork_test.go @@ -12,12 +12,18 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { dir := privateTempDir(t) logPath := filepath.Join(dir, "child.log") pidPath := filepath.Join(dir, "child.pid") + t.Setenv(spawnTestChildEnv, "1") // argv[0] is ignored (Spawn uses os.Executable()); argv[1:] // becomes child args. Placeholder subcommand so flag parsing // wouldn't choke — runSpawnTestChild short-circuits anyway. - t.Setenv(spawnTestChildEnv, "1") - pid, err := Spawn([]string{"oac-daemon", "child-mode"}, ReExecOptions{LogPath: logPath, PIDPath: pidPath}) + pid, err := Spawn( + []string{"oac-daemon", "child-mode"}, + ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + }, + ) if err != nil { t.Fatalf("Spawn: %v", err) } diff --git a/apps/daemon/internal/dispatch/assignment_test.go b/apps/daemon/internal/dispatch/assignment_test.go index 665e99c7e..fbef907e8 100644 --- a/apps/daemon/internal/dispatch/assignment_test.go +++ b/apps/daemon/internal/dispatch/assignment_test.go @@ -64,7 +64,7 @@ func TestAssignmentRejectsStaleAndForeignFrames(t *testing.T) { } foreign := ref("s") foreign.AssignmentID = "foreign" - prepare := proto.ExecutionPreparePayload{SessionID: "s", Configuration: proto.PromptRequestPayload{AgentKind: "fake_alpha", AgentStateKey: stateKey("s"), StrictResume: true, DisableExecutionEnvironment: true}} + prepare := noEnvironmentPreparation("s", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) for id, test := range map[string]struct { ref proto.AssignmentRef code string diff --git a/apps/daemon/internal/dispatch/cancellation_test.go b/apps/daemon/internal/dispatch/cancellation_test.go new file mode 100644 index 000000000..7161b9ef5 --- /dev/null +++ b/apps/daemon/internal/dispatch/cancellation_test.go @@ -0,0 +1,121 @@ +package dispatch_test + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type cancelReceiptSession struct { + *fakeSession + entered chan struct{} + release chan struct{} + err error + outcome proto.DonePayload +} + +func (s *cancelReceiptSession) CancellationOutcome() proto.DonePayload { + return s.outcome +} + +func (s *cancelReceiptSession) Cancel(ctx context.Context) error { + if s.entered != nil { + close(s.entered) + <-s.release + s.entered = nil + } + _ = s.fakeSession.Cancel(ctx) + return s.err +} + +func registerCancelReceiptKind(h *harness, sess *cancelReceiptSession) { + registerSession(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} + return sess, nil + }) +} + +func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} + registerCancelReceiptKind(h, sess) + startRun(t, h.router, h.sender, "codex", "release") + sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) + <-sess.entered + if len(h.sender.typesFor("release")) != 0 { + t.Fatal("completion acknowledged before native writer was released") + } + close(sess.release) + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "release") }, "release completion") + if frames := h.sender.typesFor("release"); len(frames) != 1 || frames[0] != proto.TypeDone || sess.cancels() != 1 { + t.Fatal("completion or native release missing") + } +} + +func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { + observed := proto.DonePayload{Content: "partial output", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} + for _, test := range []struct { + name string + outcome proto.DonePayload + err error + }{ + {name: "observed", outcome: observed}, + {name: "unknown"}, + {name: "failed", outcome: observed, err: errors.New("adapter could not cancel")}, + {name: "unsupported", outcome: observed, err: agent.ErrUnsupportedOperation}, + {name: "deadline", outcome: observed, err: context.DeadlineExceeded}, + } { + t.Run(test.name, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{}), outcome: test.outcome, err: test.err} + registerCancelReceiptKind(h, sess) + startRun(t, h.router, h.sender, "codex", "run") + if err := h.router.Handle(context.Background(), scoped(t, "run", proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})); err != nil { + t.Fatal(err) + } + <-sess.entered + if len(cancellationAcks(h.sender)) != 0 { + t.Fatal("cancellation acknowledged before adapter returned") + } + close(sess.release) + waitFor(t, func() bool { return len(cancellationAcks(h.sender)) != 0 }, "cancellation receipt") + acks := cancellationAcks(h.sender) + if len(acks) != 1 { + t.Fatalf("cancellation receipts: %+v", acks) + } + ack := acks[0] + if ack.Applied != (test.err == nil) || ack.DeliveryID != "cancel-1" { + t.Fatalf("wrong receipt: %+v", ack) + } + if test.err == nil { + if ack.ErrorCode != "" || ack.Outcome == nil || !reflect.DeepEqual(*ack.Outcome, test.outcome) { + t.Fatalf("cancellation receipt changed observed evidence: %+v", ack) + } + } else if ack.ErrorCode != "cancel_failed" || ack.Outcome != nil { + t.Fatalf("failed cancellation supplied a success outcome: %+v", ack) + } + }) + } +} + +func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + startRun(t, h.router, h.sender, "fake_alpha", "legacy") + if err := h.router.Handle(context.Background(), scoped(t, "legacy", proto.TypePromptCancel, "legacy", proto.PromptCancelPayload{})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "legacy") }, "cancellation settlement") + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + t.Fatal("legacy cancellation emitted new receipt") + } + } +} diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index 3b0cb4b91..3f8991935 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -7,13 +7,16 @@ import ( ) func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if (req.LocalEnvironment != nil) == req.DisableExecutionEnvironment { + return errors.New("execution requires exactly one of local_environment and disable_execution_environment") + } if err := req.ValidateProgrammaticToolCallingDisable(caps.ProgrammaticToolCallingDisable.IsSupported()); err != nil { return err } if err := req.ValidateToolSearch(caps.ToolSearch.IsSupported()); err != nil { return err } - if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || !caps.LocalEnvironment.IsSupported()) { + if req.LocalEnvironment != nil && !caps.LocalEnvironment.IsSupported() { return errors.New("engine does not support this local Environment configuration") } if req.DisableExecutionEnvironment && !caps.EnvironmentNone.IsSupported() { diff --git a/apps/daemon/internal/dispatch/environment_test.go b/apps/daemon/internal/dispatch/environment_test.go index 03e63552a..e8d87d7a6 100644 --- a/apps/daemon/internal/dispatch/environment_test.go +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -3,6 +3,7 @@ package dispatch_test import ( "context" "errors" + "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -10,34 +11,82 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) +// assertPreparationOutcome waits for the terminal admission status of id. An +// admitted request fails in the controlled factory; a rejected one sends only +// its rejection. +func assertPreparationOutcome(t *testing.T, sender *recSender, id string, admitted bool) proto.PreparationStatusPayload { + t.Helper() + state, frames := "rejected", 1 + if admitted { + state, frames = "failed", 2 + } + status := waitPreparationStatus(t, sender, id, state, "") + if got := sender.typesFor(id); len(got) != frames { + t.Fatalf("preparation %s frames = %v, want %d status frames", id, got, frames) + } + return status +} + +func TestNoEnvironmentRejectsOtherEngineBeforeFactory(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("controlled factory stop") + }) + assign(t, h.router, preparationSessionID, "") + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeExecutionPrepare, "none", noEnvironmentPreparation(preparationSessionID, proto.PromptRequestPayload{AgentKind: "fake_alpha"}))) + if err == nil { + t.Fatal("unsupported engine was admitted") + } + if status := assertPreparationOutcome(t, h.sender, "none", false); status.ErrorCode != "unsupported_configuration" || called.Load() { + t.Fatalf("unsupported engine was started: status=%+v called=%t", status, called.Load()) + } +} + +func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { + for _, available := range []bool{false, true} { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("controlled factory stop") + }) + assign(t, h.router, preparationSessionID, "") + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "sdk", noEnvironmentPreparation(preparationSessionID, proto.PromptRequestPayload{AgentKind: "claude_sdk"}))) + assertPreparationOutcome(t, h.sender, "sdk", available) + if called.Load() != available { + t.Fatalf("factory called=%t, available=%t", called.Load(), available) + } + } +} + func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { for _, mode := range []string{"unsupported", "unavailable", "none conflict", "supported"} { t.Run(mode, func(t *testing.T) { h := localPreparationHarness(t) defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(mode != "unsupported"), EnvironmentNone: proto.CapabilitySupported})}, - prototest.ModelConfiguration(), func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary factory is forbidden") + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", + Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(mode != "unsupported")})}, + func(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + if req.LocalEnvironment == nil || req.LocalEnvironment.ID != preparationEnvironmentID { + t.Error("local descriptor lost before factory") + } + return nil, errors.New("controlled factory stop") }) - h.reg.RegisterExecutor("codex", func(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - called = true - if req.LocalEnvironment == nil || req.LocalEnvironment.ID != preparationEnvironmentID { - t.Error("local descriptor lost before factory") - } - return nil, errors.New("controlled factory stop") - }) req := preparationRequest() req.Configuration.AgentKind = "codex" req.Configuration.DisableExecutionEnvironment = mode == "none conflict" - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local", req)) - state := "rejected" - if mode == "supported" { - state = "failed" + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local", req)) + if (err == nil) != (mode == "supported") { + t.Fatalf("wrong admission for %s: %v", mode, err) } - waitPreparationStatus(t, h.sender, "local", state, "") - if called != (mode == "supported") { + assertPreparationOutcome(t, h.sender, "local", mode == "supported") + if called.Load() != (mode == "supported") { t.Fatalf("unexpected factory call for %s", mode) } }) diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 82c5d227e..9bf45b9e6 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -40,14 +40,13 @@ func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { req.RunID, req.Input = "", nil req.AgentSessionID = "" req.RequireExistingNativeSession = false - req.ReleaseOnCompletion = false data, err := json.Marshal(req) return sha256.Sum256(data), err } func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, input proto.ExecutionPreparePayload) error { req := input.Configuration - if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.AgentStateKey != "agents-api-"+input.SessionID || !req.StrictResume { + if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.AgentStateKey != "agents-api-"+input.SessionID { return r.rejectPreparation(env, "invalid_configuration") } caps, available := r.availableCapabilities(req.AgentKind) @@ -287,19 +286,22 @@ func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { owner.idleLease++ lease := owner.idleLease r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) - owner.timer = time.AfterFunc(r.idleTimeout, func() { - r.mu.Lock() - if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { - r.mu.Unlock() - return - } - owner.invalid = true - owner.closeReason = "idle_expired" - r.shutdownWG.Add(1) + owner.timer = time.AfterFunc(r.idleTimeout, func() { r.expireIdleExecutor(owner, lease) }) +} + +// expireIdleExecutor closes owner only while lease is its current idle lease. +func (r *Router) expireIdleExecutor(owner *executorState, lease uint64) { + r.mu.Lock() + if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { r.mu.Unlock() - defer r.shutdownWG.Done() - _ = r.closeExecutor(owner) - }) + return + } + owner.invalid = true + owner.closeReason = "idle_expired" + r.shutdownWG.Add(1) + r.mu.Unlock() + defer r.shutdownWG.Done() + _ = r.closeExecutor(owner) } // Close failures keep the exact owner; a later call retries only settled failures. diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 632edbde3..3db03edad 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -150,7 +150,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } } } - request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true})} + request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", DisableExecutionEnvironment: true})} admit := func(id string) proto.PreparationStatusPayload { t.Helper() handle(proto.TypeExecutionPrepare, id, request) diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index affb5d60d..0a8080804 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -67,17 +67,28 @@ func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlemen } } +// noEnvironmentPreparation prepares config for session without an execution +// environment, with the fixture model and provider. +func noEnvironmentPreparation(session string, config proto.PromptRequestPayload) proto.ExecutionPreparePayload { + config.AgentStateKey, config.DisableExecutionEnvironment = stateKey(session), true + return proto.ExecutionPreparePayload{SessionID: session, Configuration: prototest.WithModel(config)} +} func executorRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "reusable", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, DisableExecutionEnvironment: true})} + return noEnvironmentPreparation(preparationSessionID, proto.PromptRequestPayload{AgentKind: "reusable"}) +} + +// registerExecutorKind registers info for prepared execution only. +func registerExecutorKind(reg *agent.Registry, info proto.SupportedAgentKind, factory agent.ExecutorFactory) { + reg.RegisterKind(info, prototest.ModelConfiguration(), func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("prepared execution must not use the direct Session factory") + }) + reg.RegisterExecutor(info.Kind, factory) } func executorRouter(t *testing.T, owner *reusableExecutor, idle time.Duration) (*dispatch.Router, *recSender, *atomic.Int32) { t.Helper() calls := &atomic.Int32{} reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, prototest.ModelConfiguration(), func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary factory is forbidden") - }) - reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { calls.Add(1) return owner, nil }) @@ -296,6 +307,18 @@ func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { } } +func TestExecutorRejectsMissingEnvironmentBeforeFactory(t *testing.T) { + r, s, calls := executorRouter(t, &reusableExecutor{}, time.Minute) + req := executorRequest() + req.Configuration.DisableExecutionEnvironment = false + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", req)); err == nil { + t.Fatal("unsupported configuration accepted") + } + if status := waitPreparationStatus(t, s, "request", "rejected", ""); status.ErrorCode != "unsupported_configuration" || calls.Load() != 0 { + t.Fatalf("status=%+v factory calls=%d", status, calls.Load()) + } +} + func TestExecutorRejectsOutputFromAnotherTurn(t *testing.T) { e := &reusableExecutor{starts: make(chan *reusableTurn, 1)} r, s, _ := executorRouter(t, e, time.Minute) diff --git a/apps/daemon/internal/dispatch/export_test.go b/apps/daemon/internal/dispatch/export_test.go index 4c5e2a361..05d7011d7 100644 --- a/apps/daemon/internal/dispatch/export_test.go +++ b/apps/daemon/internal/dispatch/export_test.go @@ -36,3 +36,11 @@ func (r *Router) SteeringClosedForTest(runID string) bool { state := r.sessions[runID] return state != nil && state.steeringClosed } + +// RunStartedForTest reports whether runID's Turn accepts operations. +func (r *Router) RunStartedForTest(runID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + state := r.sessions[runID] + return state != nil && state.session != nil +} diff --git a/apps/daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go new file mode 100644 index 000000000..8bae728de --- /dev/null +++ b/apps/daemon/internal/dispatch/functions_native_test.go @@ -0,0 +1,222 @@ +package dispatch_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" +) + +type nativeFunctionSender chan proto.Envelope + +func (s nativeFunctionSender) Send(ctx context.Context, e proto.Envelope) error { + select { + case s <- e: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestNativeFunctionBridge(t *testing.T) { + root := os.Getenv("OAC_TEST_NATIVE_PROOF_DIR") + if root == "" { + t.Skip("explicit native Codex binary and proof directory required") + } + home, err := os.MkdirTemp(root, "daemon-functions-") + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(home, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", home) + var count atomic.Int32 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + n := count.Add(1) + raw, _ := json.MarshalIndent(body, "", " ") + _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("request-%d.json", n)), raw, 0600) + var item map[string]any + if n%2 == 1 { + if !strings.Contains(string(raw), "lookup_ticket") { + t.Error("tool was not registered") + } + item = map[string]any{"id": fmt.Sprintf("fc_%d", n), "type": "function_call", "call_id": fmt.Sprintf("call_%d", n), "name": "lookup_ticket", "arguments": `{"ticket":"42"}`, "status": "completed"} + } else { + + var request struct { + Input []struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Output json.RawMessage `json:"output"` + } `json:"input"` + } + if err := json.Unmarshal(raw, &request); err != nil { + t.Error(err) + } + found := false + for _, entry := range request.Input { + if entry.Type != "function_call_output" || entry.CallID != fmt.Sprintf("call_%d", n-1) { + continue + } + found = true + var parts []proto.InputContent + if err := json.Unmarshal(entry.Output, &parts); err != nil { + t.Error(err) + continue + } + expected := functionResultContent("TICKET-RESULT") + if !reflect.DeepEqual(parts, expected) { + t.Errorf("native result lost text/image content or order: %s", entry.Output) + } + } + if !found { + t.Error("native model did not receive function result") + } + item = map[string]any{"id": fmt.Sprintf("msg_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "FUNCTION-OK", "annotations": []any{}}}} + } + w.Header().Set("Content-Type", "text/event-stream") + send := func(kind string, data map[string]any) { + data["type"] = kind + b, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, b) + w.(http.Flusher).Flush() + } + send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "status": "in_progress", "output": []any{}}}) + send("response.output_item.added", map[string]any{"output_index": 0, "item": item}) + send("response.output_item.done", map[string]any{"output_index": 0, "item": item}) + send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "object": "response", "created_at": time.Now().Unix(), "status": "completed", "model": "gpt-5.5", "output": []any{item}}}) + })) + defer model.Close() + reg := agent.NewRegistry() + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}, codex.NewExecutorFactory()) + sender := make(nativeFunctionSender, 256) + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + await := func(kind string) proto.Envelope { + t.Helper() + for { + select { + case env := <-sender: + if env.Type == proto.TypeError { + t.Fatalf("native error: %s", env.Payload) + } + if env.Type == kind { + return env + } + case <-ctx.Done(): + t.Fatalf("waiting for %s; evidence %s", kind, home) + } + } + } + awaitReady := func(id string) proto.PreparationStatusPayload { + t.Helper() + for { + env := await(proto.TypePreparationStatus) + var status proto.PreparationStatusPayload + if env.ID != id { + continue + } + if err := env.DecodePayload(&status); err != nil { + t.Fatal(env, err) + } + if status.State == "ready" { + return status + } + if status.State != "preparing" { + t.Fatalf("preparation %s: %+v", id, status) + } + } + } + const session = "native-functions" + nativeID := "" + // Each Run owns a fresh Router, so every resume starts a new native process. + run := func(index int) { + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := router.Shutdown(ctx); err != nil { + t.Error(err) + } + }() + run := fmt.Sprintf("run-%d", index) + assign(t, router, session, "") + request := noEnvironmentPreparation(session, proto.PromptRequestPayload{AgentKind: "codex", AgentSessionID: nativeID, + FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}}) + request.Configuration.Model, request.Configuration.ModelProvider = "gpt-5.5", &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: model.URL + "/v1", APIKey: "synthetic-local-token"} + if err := router.Handle(ctx, scoped(t, session, proto.TypeExecutionPrepare, run, request)); err != nil { + t.Fatal(err) + } + ready := awaitReady(run) + if err := router.Handle(ctx, scoped(t, session, proto.TypeExecutionStart, run, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: run, Input: proto.TextInput("Look up ticket 42.")})); err != nil { + t.Fatal(err) + } + call := await(proto.TypeFunctionCall) + var payload proto.FunctionCallPayload + if err := call.DecodePayload(&payload); err != nil || call.ID != run || payload.Name != "lookup_ticket" { + t.Fatal(call, err) + } + if index == 2 { + if err := router.Handle(ctx, scoped(t, session, proto.TypePromptCancel, run, proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + ack := await(proto.TypeInteractionDecisionAck) + var receipt proto.InteractionDecisionAckPayload + _ = ack.DecodePayload(&receipt) + if !receipt.Applied { + t.Fatal(receipt) + } + if err := router.Handle(ctx, scoped(t, session, proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: true, Content: functionResultContent("late"), DeliveryID: "late"})); err != nil { + t.Fatal(err) + } + _ = await(proto.TypeInteractionDecisionAck).DecodePayload(&receipt) + if receipt.Applied || receipt.ErrorCode != "not_pending" { + t.Fatal(receipt) + } + return + } + if err := router.Handle(ctx, scoped(t, session, proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: index == 0, Content: functionResultContent("TICKET-RESULT"), DeliveryID: "result"})); err != nil { + t.Fatal(err) + } + ack := await(proto.TypeInteractionDecisionAck) + var receipt proto.InteractionDecisionAckPayload + _ = ack.DecodePayload(&receipt) + if !receipt.Applied { + t.Fatal(receipt) + } + done := await(proto.TypeDone) + var output proto.DonePayload + _ = done.DecodePayload(&output) + id, _ := output.Metadata[proto.DoneMetaAgentSessionID].(string) + if output.Content != "FUNCTION-OK" || id == "" || (nativeID != "" && id != nativeID) { + t.Fatal(output) + } + nativeID = id + } + for index := range 3 { + run(index) + } + t.Logf("Native function success/failure, fresh-process resume, cancellation and late-result rejection passed; evidence %s", home) +} diff --git a/apps/daemon/internal/dispatch/functions_test.go b/apps/daemon/internal/dispatch/functions_test.go index f44e8cac0..55fa0b339 100644 --- a/apps/daemon/internal/dispatch/functions_test.go +++ b/apps/daemon/internal/dispatch/functions_test.go @@ -10,6 +10,7 @@ import ( "image/color" "image/png" "sync" + "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -143,29 +144,28 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { } // rejectsBeforeFactory reports whether a Router rejects preparing req, whose -// kind declares caps, before the kind's factory. +// kind declares caps, as an unsupported configuration before the kind's +// factory. func rejectsBeforeFactory(t *testing.T, caps proto.AgentKindCapabilities, req proto.PromptRequestPayload) bool { t.Helper() reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: req.AgentKind, Available: true, Capabilities: caps}, prototest.ModelConfiguration(), func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary factory is forbidden") + var called atomic.Bool + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: req.AgentKind, Available: true, Capabilities: caps}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("unexpected executor preparation") }) - reg.RegisterExecutor(req.AgentKind, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { - called = true - return nil, errors.New("controlled factory stop") - }) - router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: &recSender{}}) + sender := &recSender{} + router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) defer router.Shutdown(context.Background()) assign(t, router, preparationSessionID, "") - req.AgentStateKey, req.StrictResume, req.DisableExecutionEnvironment = stateKey(preparationSessionID), true, true - err := router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: req})) - return err != nil && !called + err := router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", noEnvironmentPreparation(preparationSessionID, req))) + status := waitPreparationStatus(t, sender, "prepare", "rejected", "") + return err != nil && status.ErrorCode == "unsupported_configuration" && !called.Load() } func TestFunctionToolsRequireAdvertisedSupport(t *testing.T) { caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported}) - if !rejectsBeforeFactory(t, caps, prototest.WithModel(proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}})) { + if !rejectsBeforeFactory(t, caps, proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) { t.Fatal("unsupported engine silently ignored tools") } } @@ -185,7 +185,7 @@ func functionResultContent(text string) []proto.InputContent { func TestDiscoveryCannotReachAnEagerOnlyAdapter(t *testing.T) { caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported}) for _, search := range []bool{false, true} { - if !rejectsBeforeFactory(t, caps, prototest.WithModel(proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}})) { + if !rejectsBeforeFactory(t, caps, proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}}) { t.Fatal("deferred definitions reached an eager-only adapter") } } diff --git a/apps/daemon/internal/dispatch/interaction_decisions.go b/apps/daemon/internal/dispatch/interaction_decisions.go index 1e296e324..8bfb3337d 100644 --- a/apps/daemon/internal/dispatch/interaction_decisions.go +++ b/apps/daemon/internal/dispatch/interaction_decisions.go @@ -105,7 +105,7 @@ func (r *Router) dropPermission(s *sessionState, permissionID string) { // those entries linger until cleanupSession — acceptable because they // can't double-fire (the session's own pendingAskTable.Take already // guards that); cleanupSession removes the routing entry when the run -// stream closes, even if the underlying CLI remains in the idle pool. +// stream closes. func (r *Router) handlePromptForUserChoiceDecision(ctx context.Context, env proto.Envelope) error { if env.ID == "" { return errors.New("dispatch: prompt_for_user_choice_decision missing ask id (Envelope.ID empty)") diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 1a0a786aa..03b2f69c2 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -43,7 +43,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing } t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) assign(t, r, session, environment) - request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, WorkspaceReadOnly: true} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{SessionID: session, Configuration: request})); err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { } t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) assign(t, r, session, environment) - request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, WorkspaceReadOnly: true} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{SessionID: session, Configuration: request})); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/mcp_http_test.go b/apps/daemon/internal/dispatch/mcp_http_test.go index f5ebe8431..f0e8b9112 100644 --- a/apps/daemon/internal/dispatch/mcp_http_test.go +++ b/apps/daemon/internal/dispatch/mcp_http_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "strings" + "sync/atomic" "testing" "time" @@ -14,33 +15,39 @@ import ( ) func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { - for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { + for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "no declaration", "required", "required old peer", "optional old peer"} { t.Run(mode, func(t *testing.T) { - h := newHarness(t) + // Service-origin servers need a service execution host, never a local Environment. + var h *harness + prepare := noEnvironmentPreparation(preparationSessionID, proto.PromptRequestPayload{AgentKind: "codex"}) + if strings.HasSuffix(mode, "local") { + h = localPreparationHarness(t) + prepare = preparationRequest() + prepare.Configuration.AgentKind = "codex" + } else { + h = newHarness(t) + assign(t, h.router, preparationSessionID, "") + } defer h.router.Shutdown(context.Background()) token := "synthetic-private-token" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} - req := prototest.WithModel(proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: stateKey(preparationSessionID), StrictResume: true, DisableExecutionEnvironment: true, MCPHTTPServers: &servers}) - caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported}) + req := &prepare.Configuration + req.MCPHTTPServers = &servers + caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported}) switch mode { case "claude", "claude old peer", "claude local": req.AgentKind = "claude_sdk" caps.MCPHTTPBearerAuth = proto.CapabilityFromBool(mode != "claude old peer") - if mode == "claude local" { - req.DisableExecutionEnvironment = false - } case "required", "required old peer", "optional old peer": servers[0].Required = mode != "optional old peer" servers[0].BearerToken = nil caps.MCPHTTPRequired = proto.CapabilityFromBool(mode == "required") - case "no bearer capability", "credential-free", "product": + case "no bearer capability", "credential-free", "no declaration": caps.MCPHTTPBearerAuth = proto.CapabilityUnsupported case "no MCP capability": caps.MCPHTTPTools = proto.CapabilityUnsupported case "no none capability": caps.EnvironmentNone = proto.CapabilityUnsupported - case "local": - req.DisableExecutionEnvironment = false case "other engine": req.AgentKind = "other" case "HTTP": @@ -49,38 +56,30 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { if mode == "credential-free" { servers[0].BearerToken = nil } - if mode == "product" { - req.MCPHTTPServers, req.DisableExecutionEnvironment = nil, false + if mode == "no declaration" { + req.MCPHTTPServers = nil } - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, - prototest.ModelConfiguration(), func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary factory is forbidden") + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, + func(_ context.Context, got proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + if mode == "required" && !(*got.MCPHTTPServers)[0].Required { + t.Error("required initialization lost before adapter") + } + if (mode == "supported" || mode == "claude") && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != token) { + t.Error("token lost before adapter") + } + return nil, errors.New("controlled factory stop") }) - h.reg.RegisterExecutor(req.AgentKind, func(_ context.Context, got proto.PromptRequestPayload) (agent.Executor, error) { - called = true - if mode == "required" && !(*got.MCPHTTPServers)[0].Required { - t.Error("required initialization lost before adapter") - } - if (mode == "supported" || mode == "claude") && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != token) { - t.Error("token lost before adapter") - } - return nil, errors.New("controlled factory stop") - }) - assign(t, h.router, preparationSessionID, "") - err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "mcp-bearer", proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: req})) - admitted := mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer" - state := "rejected" - if admitted { - state = "failed" - } - waitPreparationStatus(t, h.sender, "mcp-bearer", state, "") - if called != admitted || (err == nil) != admitted { + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "mcp-bearer", prepare)) + admitted := mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "no declaration" || mode == "required" || mode == "optional old peer" + assertPreparationOutcome(t, h.sender, "mcp-bearer", admitted) + if called.Load() != admitted || (err == nil) != admitted { t.Fatal("wrong factory admission", err) } raw, _ := json.Marshal(h.sender.snapshot()) if err != nil && strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) { - t.Fatal("rejection exposed the credential") + t.Fatal("terminal status exposed credential") } }) } diff --git a/apps/daemon/internal/dispatch/optional_interactions_test.go b/apps/daemon/internal/dispatch/optional_interactions_test.go new file mode 100644 index 000000000..b799480bc --- /dev/null +++ b/apps/daemon/internal/dispatch/optional_interactions_test.go @@ -0,0 +1,43 @@ +package dispatch_test + +import ( + "context" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +// Wrapping Session proves that no responder stubs are required for a Session. +type lifecycleOnly struct{ agent.Session } + +func TestOptionalInteractionResponders(t *testing.T) { + for _, ask := range []bool{false, true} { + t.Run(map[bool]string{false: "permission", true: "user choice"}[ask], func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + var output chan<- proto.Envelope + registerSession(h.reg, proto.SupportedAgentKind{Kind: "minimal", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + output = out + s := &fakeSession{out: out, closeOutOnCancel: true} + return lifecycleOnly{Session: s}, nil + }) + startRun(t, h.router, h.sender, "minimal", "run") + event := mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "interaction", Tool: "fixture"}) + decision := scoped(t, "run", proto.TypePermissionDecision, "interaction", proto.PermissionDecisionPayload{DeliveryID: "decision", Approved: true}) + if ask { + event = mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "interaction"}) + decision = scoped(t, "run", proto.TypePromptForUserChoiceDecision, "interaction", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "decision"}) + } + // An inconsistent adapter emitted an interaction it cannot answer: reject it, + // never acknowledge application or call a fabricated responder. + output <- event + waitFor(t, func() bool { return hasFrame(h.sender, event.Type, "run") }, "interaction indexed") + if err := h.router.Handle(t.Context(), decision); err != nil { + t.Fatal(err) + } + assertDecisionAck(t, h.sender, "decision", false, "unsupported") + }) + } +} diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index 0bcbaa630..3dea80ef8 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -63,7 +63,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if err != nil { return r.rejectPreparation(env, "invalid_configuration") } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" || !req.StrictResume || !req.ReleaseOnCompletion { + if req.RunID != "" || len(req.Input) != 0 || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" { return r.rejectPreparation(env, "invalid_configuration") } if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported()) { diff --git a/apps/daemon/internal/dispatch/preparation_start.go b/apps/daemon/internal/dispatch/preparation_start.go index 089338a70..2c09428c0 100644 --- a/apps/daemon/internal/dispatch/preparation_start.go +++ b/apps/daemon/internal/dispatch/preparation_start.go @@ -78,7 +78,7 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err } p.status.State, p.status.RunID, p.status.Revision = "starting", input.RunID, p.status.Revision+1 p.startFingerprint, p.busy = fingerprint, true - state := &sessionState{assignment: env.Assignment, capabilities: p.capabilities, runID: input.RunID, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), traceparent: env.Trace} + state := &sessionState{assignment: env.Assignment, capabilities: p.capabilities, runID: input.RunID, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), traceparent: env.Trace} state.preparedHandoff = newPreparedHandoff(p, owner.native) p.handoff, owner.run = state.preparedHandoff, state r.sessions[input.RunID] = state diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 57e89950c..bd4fbe3ea 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -115,7 +115,7 @@ func localPreparationHarness(t *testing.T) *harness { func stateKey(session string) string { return "agents-api-" + session } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: stateKey(preparationSessionID), StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}})} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: stateKey(preparationSessionID), LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}})} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { @@ -371,14 +371,12 @@ func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ - "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, - "input": func(p *proto.PromptRequestPayload) { p.Input = proto.TextInput("input") }, - "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, + "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, + "input": func(p *proto.PromptRequestPayload) { p.Input = proto.TextInput("input") }, "attachment": func(p *proto.PromptRequestPayload) { p.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} }, "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, - "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, } { t.Run(name, func(t *testing.T) { r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { diff --git a/apps/daemon/internal/dispatch/prepared_handoff.go b/apps/daemon/internal/dispatch/prepared_handoff.go index d4c232b18..16bed1669 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff.go +++ b/apps/daemon/internal/dispatch/prepared_handoff.go @@ -77,14 +77,11 @@ func newPreparedHandoff(p *preparationState, target agent.Executor) *preparedHan // preparedOperationLocked admits one mutation at the same linearization point // used by release. The returned function must run after the native call, replay // bookkeeping and receipt send have all finished. Router.mu must be held. -func (r *Router) preparedOperationLocked(state *sessionState) (agent.Session, func(), bool) { +func (r *Router) preparedOperationLocked(state *sessionState) (agent.Turn, func(), bool) { if state == nil || state.session == nil || state.steeringClosed { return nil, nil, false } handoff := state.preparedHandoff - if handoff == nil { - return state.session, func() {}, true - } if handoff.release != nil { return nil, nil, false } @@ -93,13 +90,7 @@ func (r *Router) preparedOperationLocked(state *sessionState) (agent.Session, fu } func (r *Router) interactionRouteOpenLocked(state *sessionState) bool { - if state == nil || r.closed || state.ctx.Err() != nil || state.steeringClosed { - return false - } - if handoff := state.preparedHandoff; handoff != nil { - return handoff.release == nil - } - return state.session != nil + return state != nil && !r.closed && state.ctx.Err() == nil && !state.steeringClosed && state.preparedHandoff.release == nil } // claimPreparedReleaseLocked closes admission permanently and returns the @@ -271,6 +262,11 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof } if !owner.invalid { r.scheduleExecutorIdleLocked(owner) + } else if owner.closeDone == nil { + // Shutdown invalidated this owner after the reuse decision above and + // left its close to this Run, which held it. + r.shutdownWG.Add(1) + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() } r.mu.Unlock() diff --git a/apps/daemon/internal/dispatch/receipt_order_test.go b/apps/daemon/internal/dispatch/receipt_order_test.go new file mode 100644 index 000000000..2250c7c4d --- /dev/null +++ b/apps/daemon/internal/dispatch/receipt_order_test.go @@ -0,0 +1,175 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type blockedReceiptSender struct { + recSender + once sync.Once + entered chan struct{} + release chan struct{} + exited chan struct{} + fail bool +} + +func (s *blockedReceiptSender) Send(ctx context.Context, env proto.Envelope) error { + blocked := false + if env.Type == proto.TypePromptSteerAck { + s.once.Do(func() { blocked = true }) + } + if blocked { + close(s.entered) + defer close(s.exited) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + if s.fail { + return errors.New("receipt transport failed") + } + } + return s.recSender.Send(ctx, env) +} + +func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { + for _, mode := range []string{"consumed", "unknown", "send_failure"} { + t.Run(mode, func(t *testing.T) { + sender := &blockedReceiptSender{entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{}), fail: mode == "send_failure"} + registry := agent.NewRegistry() + var session *fakeSession + var calls atomic.Int32 + registerSession(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &steeringSession{fakeSession: session, steer: func(context.Context, proto.PromptSteerPayload) error { + calls.Add(1) + if mode == "unknown" { + return errors.New("native outcome unknown") + } + return nil + }}, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + var release sync.Once + defer release.Do(func() { close(sender.release) }) + handle := func(kind string, payload any) { + t.Helper() + if err := router.Handle(context.Background(), scoped(t, "ordered", kind, "ordered", payload)); err != nil { + t.Fatal(err) + } + } + startRun(t, router, &sender.recSender, "codex", "ordered") + base := len(sender.snapshot()) + input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("original")} + handle(proto.TypePromptSteer, input) + <-sender.entered + session.out <- mustEnv(t, proto.TypeDone, "ordered", proto.DonePayload{Content: "finished"}) + waitFor(t, func() bool { return router.SteeringClosedForTest("ordered") }, "closed steering admission") + // Native settlement precedes the receipt join; TestPreparedHandoffReleaseWaitsForMutationReceipt asserts that order. + if len(sender.snapshot()) != base { + t.Fatal("Done overtook receipt") + } + if mode != "send_failure" { + handle(proto.TypePromptSteer, input) + frames := sender.snapshot()[base:] + var ack proto.PromptSteerAckPayload + if len(frames) != 1 || frames[0].DecodePayload(&ack) != nil || ack.Accepted != (mode == "consumed") { + t.Fatalf("cached receipt lost: %+v", ack) + } + if mode == "unknown" && ack.ErrorCode != "outcome_unknown" { + t.Fatal("uncertainty lost") + } + input.Input = proto.TextInput("changed") + handle(proto.TypePromptSteer, input) + input.InputID = "new" + handle(proto.TypePromptSteer, input) + frames = sender.snapshot()[base:] + for i, want := range []string{"input_conflict", "run_inactive"} { + if frames[i+1].DecodePayload(&ack) != nil || ack.ErrorCode != want { + t.Fatalf("receipt %d: %+v", i, ack) + } + } + } + release.Do(func() { close(sender.release) }) + waitFor(t, func() bool { return hasFrame(&sender.recSender, proto.TypeDone, "ordered") }, "durable completion") + frames := sender.snapshot()[base:] + if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeDone || calls.Load() != 1 || session.cancels() != 1 { + t.Fatalf("invalid terminal order/calls: %+v, calls=%d cancels=%d", frames, calls.Load(), session.cancels()) + } + if mode == "send_failure" && len(frames) != 1 { + t.Fatal("failed send fabricated an applied receipt") + } + }) + } +} + +func TestShutdownReleasesSteeringWorkerAndReceiptJoin(t *testing.T) { + for _, phase := range []string{"native", "receipt_send"} { + t.Run(phase, func(t *testing.T) { + sender := &blockedReceiptSender{entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} + registry := agent.NewRegistry() + entered, exited := make(chan struct{}), make(chan struct{}) + var session *fakeSession + registerSession(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &steeringSession{fakeSession: session, steer: func(ctx context.Context, _ proto.PromptSteerPayload) error { + close(entered) + defer close(exited) + if phase == "native" { + <-ctx.Done() + return ctx.Err() + } + return nil + }}, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + startRun(t, router, &sender.recSender, "codex", "shutdown") + if err = router.Handle(context.Background(), scoped(t, "shutdown", proto.TypePromptSteer, "shutdown", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text")})); err != nil { + t.Fatal(err) + } + <-entered + if phase == "receipt_send" { + <-sender.entered + session.out <- mustEnv(t, proto.TypeDone, "shutdown", proto.DonePayload{}) + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown") }, "receipt join") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err = router.Shutdown(ctx); err != nil { + t.Fatal(err) + } + select { + case <-exited: + default: + t.Fatal("native receipt worker leaked") + } + select { + case <-sender.exited: + default: + t.Fatal("receipt send worker leaked") + } + if router.ActiveRuns() != 0 { + t.Fatal("run remained after shutdown") + } + }) + } +} diff --git a/apps/daemon/internal/dispatch/receipt_shutdown_test.go b/apps/daemon/internal/dispatch/receipt_shutdown_test.go new file mode 100644 index 000000000..76316068f --- /dev/null +++ b/apps/daemon/internal/dispatch/receipt_shutdown_test.go @@ -0,0 +1,77 @@ +package dispatch_test + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "testing" + "time" +) + +type shutdownAllSendsBlockSender struct { + recSender + entered chan struct{} + terminal chan context.Context + rescue chan struct{} +} + +func (s *shutdownAllSendsBlockSender) Send(ctx context.Context, env proto.Envelope) error { + if env.Type == proto.TypePromptSteerAck { + close(s.entered) + select { + case <-ctx.Done(): + <-time.After(50 * time.Millisecond) + return ctx.Err() + case <-s.rescue: + return errors.New("test cleanup") + } + } + if env.Type == proto.TypeError { + s.terminal <- ctx + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.rescue: + return errors.New("test cleanup") + } + } + return s.recSender.Send(ctx, env) +} + +func TestShutdownCancelsCompletionErrorSend(t *testing.T) { + sender := &shutdownAllSendsBlockSender{entered: make(chan struct{}), terminal: make(chan context.Context, 1), rescue: make(chan struct{})} + registry := agent.NewRegistry() + var session *fakeSession + registerSession(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + session = &fakeSession{out: out, closeOutOnCancel: true} + return &steeringSession{fakeSession: session, steer: func(context.Context, proto.PromptSteerPayload) error { return nil }}, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + if err != nil { + t.Fatal(err) + } + startRun(t, router, &sender.recSender, "codex", "shutdown-terminal") + if err = router.Handle(context.Background(), scoped(t, "shutdown-terminal", proto.TypePromptSteer, "shutdown-terminal", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text")})); err != nil { + t.Fatal(err) + } + <-sender.entered + session.out <- mustEnv(t, proto.TypeDone, "shutdown-terminal", proto.DonePayload{}) + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown-terminal") }, "receipt join") + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + shutdownErr := router.Shutdown(ctx) + active := router.ActiveRuns() + select { + case terminalCtx := <-sender.terminal: + t.Logf("error send context: Done is nil=%t, Err=%v", terminalCtx.Done() == nil, terminalCtx.Err()) + default: + } + close(sender.rescue) + waitFor(t, func() bool { return router.ActiveRuns() == 0 }, "test cleanup") + if shutdownErr != nil || active != 0 { + t.Fatalf("cooperating receipt sender canceled, but shutdown failed: err=%v active_runs=%d", shutdownErr, active) + } +} diff --git a/apps/daemon/internal/dispatch/router.go b/apps/daemon/internal/dispatch/router.go index 6b8a1caac..a9c59e368 100644 --- a/apps/daemon/internal/dispatch/router.go +++ b/apps/daemon/internal/dispatch/router.go @@ -1,8 +1,8 @@ // Package dispatch wires inbound WebSocket frames to the agent layer. -// It owns the Session assignments of its connection, one prepared run per -// active RunID with a goroutine that forwards the run's events to the -// transport, and a permission_id → run_id index so permission_decision -// frames route back to the right run. +// It owns the Session assignments of its connection, one Turn per active +// RunID with a goroutine that forwards the Turn's events to the transport, +// and a permission_id → run_id index so permission_decision frames route +// back to the right run. // // Concurrency: Handle is safe for one goroutine (typically the read // loop). Each run has its own goroutines. Internal state is @@ -70,19 +70,18 @@ type appliedInteractionDecision struct { } // sessionState is the dispatcher's per-run bookkeeping. The agent -// owns the close of out; the dispatcher cancels ctxCancel to wind -// down. traceparent captures the execution_start's W3C trace so every -// outbound frame stamps env.Trace with the same value, completing +// owns the close of out; preparedHandoff owns release. traceparent +// captures the execution_start's W3C trace so every outbound frame +// stamps env.Trace with the same value, completing // frontend → server → daemon → agent → server attribution. type sessionState struct { assignment proto.AssignmentRef capabilities proto.AgentKindCapabilities runID string environmentID string - session agent.Session + session agent.Turn out chan proto.Envelope ctx context.Context - ctxCancel context.CancelFunc pendingIDs map[string]struct{} pendingAsks map[string]struct{} traceparent string diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index dc805e279..1d755905b 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -3,6 +3,7 @@ package dispatch_test import ( "context" "errors" + "slices" "sync" "testing" "time" @@ -177,12 +178,12 @@ func registerSession(reg *agent.Registry, info proto.SupportedAgentKind, factory })) } -// startRun binds the Session run to r, prepares its Executor of kind and -// starts run. sender records r's frames. +// startRun binds the Session run to r, prepares its Executor of kind, starts +// run and waits until it accepts operations. sender records r's frames. func startRun(t *testing.T, r *dispatch.Router, sender *recSender, kind, run string) { t.Helper() assign(t, r, run, "") - prepare := scoped(t, run, proto.TypeExecutionPrepare, "prepare-"+run, proto.ExecutionPreparePayload{SessionID: run, Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: kind, AgentStateKey: stateKey(run), StrictResume: true, DisableExecutionEnvironment: true})}) + prepare := scoped(t, run, proto.TypeExecutionPrepare, "prepare-"+run, noEnvironmentPreparation(run, proto.PromptRequestPayload{AgentKind: kind})) if err := r.Handle(t.Context(), prepare); err != nil { t.Fatalf("execution_prepare: %v", err) } @@ -191,6 +192,7 @@ func startRun(t *testing.T, r *dispatch.Router, sender *recSender, kind, run str t.Fatalf("execution_start: %v", err) } waitPreparationStatus(t, sender, prepare.ID, "started", "") + waitFor(t, func() bool { return r.RunStartedForTest(run) }, "run "+run+" to accept operations") } // ref is the assignment the tests bind session to. @@ -228,6 +230,99 @@ func mustEnv(t *testing.T, typ, id string, payload any) proto.Envelope { // tests // --------------------------------------------------------------------- +func TestExecutionStartRunsInputAndForwardsOutput(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + startRun(t, h.router, h.sender, "fake_alpha", "run_1") + req := <-h.gotReq + sess := <-h.gotSess + if req.RunID != "run_1" || len(req.Input) != 1 || *req.Input[0].Content[0].Text != "input" { + t.Errorf("Turn got run %q input %+v, want run_1/input", req.RunID, req.Input) + } + + // Session emits a delta + done; both should reach the sender. + sess.out <- mustEnv(t, proto.TypeDelta, "run_1", proto.DeltaPayload{Delta: "hello", Sequence: 1}) + sess.out <- mustEnv(t, proto.TypeDone, "run_1", proto.DonePayload{Content: "hello"}) + + waitForTypes(t, h.sender, "run_1", []string{proto.TypeDelta, proto.TypeDone}) + + // Settlement should have removed the Run. + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active runs to drop to 0") +} + +func TestExecutionStartRejectsDuplicateRunID(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + startRun(t, h.router, h.sender, "fake_alpha", "run_dup") + <-h.gotReq + + // Another Executor must not start a second Turn for the same RunID. + assign(t, h.router, "session-dup", "") + second := executorAdmission(t, h.router, h.sender, "prepare-dup", noEnvironmentPreparation("session-dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + start := scoped(t, "session-dup", proto.TypeExecutionStart, "prepare-dup", proto.ExecutionStartPayload{Handle: second.Handle, ExecutorID: second.ExecutorID, RunID: "run_dup", Input: proto.TextInput("input")}) + if err := h.router.Handle(context.Background(), start); err == nil { + t.Fatal("duplicate Run started") + } + if status := waitPreparationStatus(t, h.sender, "prepare-dup", "rejected", ""); status.ErrorCode != "run_conflict" { + t.Fatalf("duplicate start status = %+v, want run_conflict", status) + } + select { + case extra := <-h.gotReq: + t.Fatalf("Turn started twice for duplicate run, second run=%s", extra.RunID) + default: + } +} + +func TestExecutionPrepareRejectsUnknownKind(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + assign(t, h.router, "x", "") + env := scoped(t, "x", proto.TypeExecutionPrepare, "prepare_x", noEnvironmentPreparation("x", proto.PromptRequestPayload{AgentKind: "fake_beta"})) + if err := h.router.Handle(context.Background(), env); err == nil { + t.Error("unknown kind admitted") + } + if status := waitPreparationStatus(t, h.sender, "prepare_x", "rejected", ""); status.ErrorCode != "resource_unavailable" { + t.Errorf("unknown kind status = %+v, want resource_unavailable", status) + } + if got, want := h.sender.typesFor("prepare_x"), []string{proto.TypePreparationStatus}; !slices.Equal(got, want) { + t.Errorf("sender frames for prepare_x = %v, want %v", got, want) + } +} + +func TestExecutionStartRequiresRunID(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + assign(t, h.router, preparationSessionID, "") + ready := executorAdmission(t, h.router, h.sender, "prepare", noEnvironmentPreparation(preparationSessionID, proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + start := mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, Input: proto.TextInput("input")}) + if err := h.router.Handle(context.Background(), start); err == nil { + t.Fatal("expected error on missing run id") + } + if status := waitPreparationStatus(t, h.sender, "prepare", "rejected", ""); status.ErrorCode != "invalid_start" { + t.Fatalf("missing run id status = %+v, want invalid_start", status) + } +} + +func TestHandlePromptCancelInvokesSessionCancel(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + startRun(t, h.router, h.sender, "fake_alpha", "run_2") + <-h.gotReq + sess := <-h.gotSess + + if err := h.router.Handle(context.Background(), scoped(t, "run_2", proto.TypePromptCancel, "run_2", nil)); err != nil { + t.Fatalf("prompt_cancel: %v", err) + } + + waitFor(t, func() bool { return sess.cancels() == 1 }, "session.Cancel to fire once") + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "session to be cleaned up") +} + func TestHandlePromptCancelUnknownRunIsNoop(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) @@ -285,6 +380,53 @@ func TestPermissionDecisionUnknownPermIsNoop(t *testing.T) { assertDecisionAck(t, h.sender, "delivery-unknown-perm", false, "not_pending") } +func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + startRun(t, h.router, h.sender, "fake_alpha", "run_ask") + <-h.gotReq + sess := <-h.gotSess + + // Envelope.ID is the run id (server-side dispatch fans on it); the + // ask id rides on the payload. Daemon's indexPermissionFrame reads + // payload.AskID to seed askIndex. + sess.out <- mustEnv(t, proto.TypePromptForUserChoice, "run_ask", proto.PromptForUserChoicePayload{ + AskID: "ask_abcd1234", + Questions: []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "yes"}, {Label: "no"}}}}, + ToolUseID: "toolu_42", + }) + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypePromptForUserChoice, "run_ask") }, "prompt_for_user_choice forwarded") + + dec := scoped(t, "run_ask", proto.TypePromptForUserChoiceDecision, "ask_abcd1234", proto.PromptForUserChoiceDecisionPayload{ + DeliveryID: "delivery-ask-1", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, + }) + if err := h.router.Handle(context.Background(), dec); err != nil { + t.Fatalf("prompt_for_user_choice_decision: %v", err) + } + + sess.askMu.Lock() + calls := append([]askCall(nil), sess.askCalls...) + sess.askMu.Unlock() + if len(calls) != 1 || calls[0].id != "ask_abcd1234" { + t.Fatalf("askCalls = %+v, want one ask_abcd1234", calls) + } + if len(calls[0].decision.QuestionAnswers[0].Answers) != 1 || calls[0].decision.QuestionAnswers[0].Answers[0] != "yes" { + t.Errorf("answer payload mismatch: %+v", calls[0].decision) + } + assertDecisionAck(t, h.sender, "delivery-ask-1", true, "") + + // Cleanup contract: a successful decision drops the ask from both + // the router-level index and the session's pendingAsks set, so a + // stale retry short-circuits as "run gone". + if got := h.router.AskIndexLenForTest(); got != 0 { + t.Errorf("askIndex len = %d, want 0 after decision", got) + } + if got := h.router.PendingAsksLenForTest("run_ask"); got != 0 { + t.Errorf("pendingAsks len = %d, want 0 after decision", got) + } +} + // TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown locks in the // cleanup branch: when the session returns ErrUnknownAsk (timer already // consumed the entry), the router still drops the index so a retry doesn't @@ -403,10 +545,49 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { } } +func TestShutdownWaitsForPumpDrain(t *testing.T) { + h := newHarness(t) + + startRun(t, h.router, h.sender, "fake_alpha", "rs") + <-h.gotReq + sess := <-h.gotSess + sess.closeOutOnCancel = false + + // Background: emit one frame then close out shortly after + // shutdown is asked for. + go func() { + sess.out <- mustEnv(t, proto.TypeDelta, "rs", proto.DeltaPayload{Delta: "x"}) + time.Sleep(20 * time.Millisecond) + close(sess.out) + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := h.router.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown returned %v before pump drained", err) + } + if h.router.ActiveRuns() != 0 { + t.Errorf("ActiveRuns after Shutdown = %d, want 0", h.router.ActiveRuns()) + } +} + // --------------------------------------------------------------------- // helpers // --------------------------------------------------------------------- +func waitForTypes(t *testing.T, s *recSender, runID string, want []string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + got := s.typesFor(runID) + if slices.Equal(got, want) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("never observed types %v for run %s; got %v", want, runID, s.typesFor(runID)) +} + func waitFor(t *testing.T, cond func() bool, what string) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/apps/daemon/internal/dispatch/shutdown.go b/apps/daemon/internal/dispatch/shutdown.go index de4dd9133..ef27133b4 100644 --- a/apps/daemon/internal/dispatch/shutdown.go +++ b/apps/daemon/internal/dispatch/shutdown.go @@ -28,13 +28,8 @@ func (r *Router) Shutdown(ctx context.Context) error { } } - first := !r.closed - var victims []sessionCancellation - for _, state := range r.sessions { - release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) - victims = append(victims, sessionCancellation{runID: state.runID, release: release, attempt: attempt}) - } - if first { + victims := r.sessionCancellationsLocked() + if !r.closed { r.closed = true if r.runtimePreparation != nil { r.runtimePreparation.cancel() @@ -93,7 +88,11 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC } } for _, owner := range r.executors { - attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: executor %s cleanup unconfirmed: %w", owner.id, owner.closeErr)) + cause := owner.closeErr + if cause == nil { + cause = errors.New("cleanup has not settled") + } + attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: executor %s: %w", owner.id, cause)) } close(attempt.done) r.mu.Unlock() @@ -117,6 +116,18 @@ func waitShutdown(ctx context.Context, attempt *shutdownAttempt) error { type sessionCancellation struct { runID string + handoff *preparedHandoff release *preparedRelease attempt *preparedReleaseAttempt } + +// sessionCancellationsLocked claims release of every active Run, retrying a +// failed native attempt. Router.mu must be held. +func (r *Router) sessionCancellationsLocked() []sessionCancellation { + victims := make([]sessionCancellation, 0, len(r.sessions)) + for _, state := range r.sessions { + release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) + victims = append(victims, sessionCancellation{runID: state.runID, handoff: state.preparedHandoff, release: release, attempt: attempt}) + } + return victims +} diff --git a/apps/daemon/internal/dispatch/steering.go b/apps/daemon/internal/dispatch/steering.go index b4cd28629..5a0ab04c3 100644 --- a/apps/daemon/internal/dispatch/steering.go +++ b/apps/daemon/internal/dispatch/steering.go @@ -102,12 +102,7 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr } session := state.session steerer, supportsSteering := session.(agent.Steerer) - if input.DurableReceipt { - if _, ok := session.(agent.DurableSteerer); !ok { - ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported Turn settlement contract." - return &ack - } - } else if !supportsSteering { + if !input.DurableReceipt && !supportsSteering { ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." return &ack } diff --git a/apps/daemon/internal/dispatch/steering_lifetime.go b/apps/daemon/internal/dispatch/steering_lifetime.go index b696de6a4..adbfbe054 100644 --- a/apps/daemon/internal/dispatch/steering_lifetime.go +++ b/apps/daemon/internal/dispatch/steering_lifetime.go @@ -9,7 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, session agent.Session, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte) error { +func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, session agent.DurableSteerer, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte) error { ctx, cancel := context.WithCancel(state.ctx) defer cancel() stopShutdown := context.AfterFunc(sendCtx, cancel) @@ -17,7 +17,7 @@ func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, sess timer := time.AfterFunc(steeringCallTimeout, cancel) defer timer.Stop() var once sync.Once - return session.(agent.DurableSteerer).SteerWithReceipt(ctx, input, func() { + return session.SteerWithReceipt(ctx, input, func() { once.Do(func() { if !timer.Stop() || ctx.Err() != nil { return diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index 4ae654dd4..b71577e7a 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -3,6 +3,7 @@ package dispatch import ( "context" "errors" + "sync/atomic" "testing" "time" @@ -23,6 +24,14 @@ func bindAssignment(r *Router, ref proto.AssignmentRef, environmentID string) { r.mu.Unlock() } +type suspendedExecutor struct{ closed atomic.Int32 } + +func (e *suspendedExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { + return nil, errors.New("suspended executor must not start a Turn") +} + +func (e *suspendedExecutor) Close(context.Context) error { e.closed.Add(1); return nil } + func suspensionRouter(t *testing.T, sender Sender) *Router { t.Helper() r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, IdleTimeout: time.Hour}) @@ -40,7 +49,7 @@ func suspensionRouter(t *testing.T, sender Sender) *Router { func TestQuiesceRejectsEveryUnsettledResource(t *testing.T) { cases := map[string]func(*Router){ - "active": func(r *Router) { r.sessions["run"] = &sessionState{ctxCancel: func() {}} }, + "active": func(r *Router) { r.sessions["run"] = &sessionState{} }, "preparing": func(r *Router) { r.preparations["p"] = &preparationState{owns: true} }, "receipt": func(r *Router) { r.preparations["p"] = &preparationState{busy: true} }, "read": func(r *Router) { r.workspaceReads = map[string]struct{}{"read": {}} }, @@ -140,6 +149,36 @@ func TestResumeRequiresExactSuspensionAndAssignment(t *testing.T) { } } +func TestQuiescePreservesIdleExecutorAgainstExpiredTimer(t *testing.T) { + sender := suspendSender(func(context.Context, proto.Envelope) error { return nil }) + r := suspensionRouter(t, sender) + native := &suspendedExecutor{} + owner := &executorState{id: "executor", sessionID: "session", environmentID: "env", native: native, cancel: func() {}} + r.mu.Lock() + r.executors[owner.sessionID] = owner + r.scheduleExecutorIdleLocked(owner) + oldLease := owner.idleLease + r.mu.Unlock() + request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"} + if err := r.Quiesce(context.Background(), suspendRef, request); err != nil { + t.Fatal(err) + } + r.expireIdleExecutor(owner, oldLease) + if native.closed.Load() != 0 { + t.Fatal("pre-snapshot timer closed retained owner") + } + if err := r.Resume(suspendRef, request, sender); err != nil { + t.Fatal(err) + } + r.mu.Lock() + newLease := owner.idleLease + r.mu.Unlock() + r.expireIdleExecutor(owner, newLease) + if native.closed.Load() != 1 { + t.Fatal("normal idle expiration was not restored") + } +} + func TestShutdownDestroysQuiescedOwnerAndCannotResume(t *testing.T) { sender := suspendSender(func(context.Context, proto.Envelope) error { return nil }) r := suspensionRouter(t, sender) diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index e747ea41e..5ee7fbf31 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -69,8 +69,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, nil } if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || - r.DisableExecutionEnvironment || - r.ConversationID != "" || !r.StrictResume { + r.DisableExecutionEnvironment { return r, errors.New("request does not match the dedicated local Environment") } if !r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "" || len(r.LocalEnvironment.AllowedDomains) > 0 { diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index 2beb90e46..6d5bc80a5 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -26,7 +26,7 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { } b.networkAccess = "disabled" b.capabilityRoot = t.TempDir() - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session} } func TestBindingRejectsScopeOverrides(t *testing.T) { @@ -40,9 +40,8 @@ func TestBindingRejectsScopeOverrides(t *testing.T) { "other Environment": func(r *proto.PromptRequestPayload) { r.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString()} }, - "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "agents-api-" + uuid.NewString() }, - "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, - "non-strict resume": func(r *proto.PromptRequestPayload) { r.StrictResume = false }, + "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "agents-api-" + uuid.NewString() }, + "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, } { t.Run(name, func(t *testing.T) { r := valid @@ -96,14 +95,6 @@ func TestBindingPrepareRejectsOtherWorkspaceRoot(t *testing.T) { } } -func TestBindingAllowsRetainedExecutor(t *testing.T) { - b, req := testBinding(t) - req.ReleaseOnCompletion = false - if _, err := b.Configure(req); err != nil { - t.Fatal(err) - } -} - func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { b, _ := testBinding(t) for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_process.go b/apps/daemon/internal/localworkspace/runtime_initialization_process.go index e98dc070b..68174485d 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_process.go @@ -110,7 +110,7 @@ func runInitializationProcess(ctx context.Context, binary string, args []string, return &InitializationFailure{} } process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, - Dir: directory, Env: env, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + Dir: directory, Env: env, KillTimeout: 250 * time.Millisecond}) if err != nil { return ErrInitializationUnconfirmed } diff --git a/apps/daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go index 50f01c100..6c835ae3d 100644 --- a/apps/daemon/internal/paths/paths.go +++ b/apps/daemon/internal/paths/paths.go @@ -1,6 +1,6 @@ // Package paths resolves on-disk locations for oac-daemon state under -// ~/.oac/daemon// — one subdir per profile so several -// devices on one host never collide. +// ~/.oac/daemon// — one subdir per profile so "test" +// and "prod" servers can be connected in parallel without colliding. // // Files are 0o600, parent dir 0o700. These functions only resolve // paths — callers do the I/O. @@ -50,7 +50,7 @@ func Root() (string, error) { return filepath.Join(home, ".oac"), nil } -// ProfileDir returns ~/.oac/daemon/. It is not created. +// ProfileDir returns ~/.oac/daemon/. It is not created here. func ProfileDir(profile string) (string, error) { if err := ValidateProfile(profile); err != nil { return "", err diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go index 8eedea2f5..e39411074 100644 --- a/apps/daemon/internal/transport/ws.go +++ b/apps/daemon/internal/transport/ws.go @@ -24,7 +24,7 @@ type DialOptions struct { // WSURL is the absolute ws://... or wss://... URL. WSURL string - // DeviceID is the runtime row id issued with the credential. Sent as + // DeviceID is the runtime row id from the daemon credential. Sent as // device_id query param; the gateway uses it as the session key. DeviceID string diff --git a/apps/daemon/internal/transport/ws_test.go b/apps/daemon/internal/transport/ws_test.go index 01cee4ff9..7e6b2fbf7 100644 --- a/apps/daemon/internal/transport/ws_test.go +++ b/apps/daemon/internal/transport/ws_test.go @@ -186,9 +186,9 @@ func TestRecvDeliversServerSentFrames(t *testing.T) { } defer conn.Close() - // Server pushes a prompt_request down to the daemon. + // Server pushes a prompt_cancel down to the daemon. serverConn := <-gw.connCh - pushed, _ := proto.NewEnvelope("prompt_request", "run_abc", map[string]string{"prompt": "hi"}) + pushed, _ := proto.NewEnvelope(proto.TypePromptCancel, "run_abc", proto.PromptCancelPayload{DeliveryID: "cancel"}) raw, _ := json.Marshal(pushed) if err := serverConn.WriteMessage(websocket.TextMessage, raw); err != nil { t.Fatalf("server write: %v", err) @@ -196,8 +196,8 @@ func TestRecvDeliversServerSentFrames(t *testing.T) { select { case env := <-conn.Recv(): - if env.Type != "prompt_request" || env.ID != "run_abc" { - t.Errorf("received %+v, want prompt_request/run_abc", env) + if env.Type != proto.TypePromptCancel || env.ID != "run_abc" { + t.Errorf("received %+v, want prompt_cancel/run_abc", env) } case <-time.After(2 * time.Second): t.Fatal("never received the server-pushed frame") diff --git a/apps/daemon/internal/wireconformance/wire_test.go b/apps/daemon/internal/wireconformance/wire_test.go index 4d8dacb00..22667ed26 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -158,7 +158,7 @@ func connectRuntime(t *testing.T, peer *corePeer, setupErr error) *runtimeSide { kinds := agent.NewRegistry() kinds.RegisterKind(proto.SupportedAgentKind{Kind: prototest.HarnessKind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, prototest.ModelConfiguration(), func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("prepared execution must not use prompt_request") + return nil, errors.New("prepared execution must not use the direct Session factory") }) kinds.RegisterExecutor(prototest.HarnessKind, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return rt.executor, setupErr diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 4c35cc0cc..5dbb2bdc3 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -38,10 +38,10 @@ type harness struct { } func (h *harness) prepare(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return nil, errors.New("preparation submitted fixture input") } - if !req.StrictResume || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { + if !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { return nil, errors.New("unsupported fixture operation") } h.mu.Lock() diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index d5a1a90ab..41149ef97 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -14,7 +14,7 @@ const browserStorage = (page: Page) => page.evaluate(() => JSON.stringify({ ...w test("signs in with the Core key, keeps it out of the browser, and signs out and back in", async ({ page, request }) => { await resetFixture(request, "login"); await recordV1Requests(page); - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.language", "en")); await page.goto("/"); await expect(page.getByRole("heading", { name: "Sign in to OpenAgentCore" })).toBeVisible(); @@ -47,7 +47,7 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and test("opens a fresh install on the Overview's Getting started: a project and its key shown once, then the step is done", async ({ page, request }) => { await resetFixture(request, "login", { fresh: true }); await recordV1Requests(page); - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.language", "en")); await page.goto("/"); await signIn(page, FIXTURE_CORE_KEY); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 78000a332..1e50de4db 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -40,7 +40,7 @@ export async function openConsole(page: Page, request: APIRequestContext, hash = await resetFixture(request, "authenticated", options); await recordV1Requests(page); await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]); - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.language", "en")); await page.goto(`/#${hash}`); } diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 721bcf6a0..f404a5de6 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -35,12 +35,12 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same const field = add.getByLabel("One-time enrollment command", { exact: true }); await expect(field).toHaveValue(/enroll_fixture_/); // The token goes on stdin to the checked installer, run with sudo unless the shell is root. - await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/); - await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); + await expect(field).toHaveValue(/^ \(umask 077\n/); + await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); // It downloads from, and names as its source, the public URL, not the loopback address this browser uses. await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /); await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /); - await expect(add.getByText("If the command is interrupted or the download stalls, run the same command again: the download resumes.")).toBeVisible(); + await expect(add.getByText("If the command is interrupted or the download stalls, run it again: unfinished downloads restart, and verified files are reused.")).toBeVisible(); await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/); const progress = add.getByRole("status", { name: "Registration progress" }); await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/); @@ -155,7 +155,7 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); // Removing the Core record leaves the system service for root/sudo to uninstall on its host. const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); - await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); await expect(cleanup.getByText("Installed without sudo?")).toHaveCount(0); await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveCount(0); // Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone. diff --git a/apps/web/e2e/overview-readiness.spec.ts b/apps/web/e2e/overview-readiness.spec.ts index 5f22542d5..540a37405 100644 --- a/apps/web/e2e/overview-readiness.spec.ts +++ b/apps/web/e2e/overview-readiness.spec.ts @@ -164,7 +164,7 @@ test("failed summary and Session refreshes preserve their previous evidence unti }); test("installation failure cannot complete onboarding and its retry reveals local-only blockage", async ({ page, request }) => { - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.getting-started.7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", "open")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.getting-started.7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", "open")); await page.route("**/core/v1/installation", (route) => route.fulfill(reject)); await openConsole(page, request, "overview", { installation: "local" }); const step = page.locator(".getting-started-step").first(); diff --git a/apps/web/index.html b/apps/web/index.html index b65773249..4bd5a5edd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -13,7 +13,7 @@ OpenAgentCore