diff --git a/.env.example b/.env.example index fd5aa9360..9c82663c2 100644 --- a/.env.example +++ b/.env.example @@ -4,46 +4,3 @@ OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:8091 # Core reads its own environment, not this file. Core settings, including # Runtime history sampling and export, are in docs/configuration.md. - -# Public, non-secret opt-in for the reviewed Codex self_hosted Session profile. -# Leave unset unless Core execution, its executor registry, and executor origin -# are configured. This flag is presentation policy, not capability discovery. -# OAC_WEB_SELF_HOSTED_SESSIONS=1 - -# Public, non-secret opt-in for the operator-qualified basic Codex -# openai_hosted Session profile. Leave unset unless Core was started with a -# qualified managed Runtime provider. This flag does not probe runtime readiness. -# OAC_WEB_OPENAI_HOSTED_SESSIONS=1 - -# Public, non-secret opt-in for the complete Environment Files profile. Leave -# unset for older Core revisions. Enable only after the connected Core has been -# qualified for Files.list and managed Files.create; self_hosted reads use its -# exact workspace_directory root. -# OAC_WEB_ENVIRONMENT_FILES=1 - -# Optional local-only Docker backend recovery guide shown in the connection -# panel. Web renders copyable `docker start` and loopback health commands; it -# never accesses the Docker socket or executes them. Values are compiled into -# the browser bundle and must contain non-secret container names only. -# OAC_WEB_DOCKER_BACKEND_GUIDE=1 -# OAC_WEB_DOCKER_DATABASE_CONTAINER=oac-web-smoke-db -# OAC_WEB_DOCKER_API_CONTAINER=oac-web-smoke-api -# OAC_WEB_DOCKER_DAEMON_CONTAINER=oac-web-smoke-daemon -# OAC_WEB_DOCKER_CORE_PORT=8091 - -# Optional local-only Docker connection recipe. This renders a copyable command; -# it never gives the browser Docker access or reads the credential file. Every -# value below is compiled into the browser bundle, so values must be non-secret. -# Enable only for the matching operator-controlled local stack. -# OAC_WEB_DOCKER_GUIDE=1 -# OAC_WEB_DOCKER_IMAGE=oac-web-smoke-executor:2b34ea46-codex-0.153.4 -# OAC_WEB_DOCKER_API_CONTAINER=oac-web-smoke-api -# OAC_WEB_DOCKER_USER=501:20 -# OAC_WEB_DOCKER_CREDENTIALS_HOME_PATH=.oac/web-smoke/executor-key.json -# OAC_WEB_DOCKER_RUNTIME_HOME_PATH=.oac/web-smoke/executors - -# Public, non-secret suggestions shown by the Create Agent model picker. The -# first entry is the default unless VITE_AGENT_DEFAULT_MODEL overrides it. These -# do not claim live availability; the connected runtime remains authoritative. -VITE_AGENT_MODEL_PRESETS=gpt-6-astra,gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna,gpt-5.5,gpt-5.3-codex-spark -VITE_AGENT_DEFAULT_MODEL=gpt-5.6-sol diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index a90f1be0a..ee010ed18 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -62,8 +62,9 @@ jobs: OAC_TEST_SERVER_BIN: ${{ runner.temp }}/oac-core-build/oac-core OAC_TEST_OFFICIAL_SDK_PYTHON: python run: | + python services/core/tests/official_schema_test.py python services/core/tests/official_client.py - go test ./services/core/internal/store -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient|TestSelfHostedFunctionsOfficialClient|TestSelfHostedSteeringOfficialClient)$' -count=1 + go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 - uses: ./.github/actions/e2b-provider if: inputs.container - name: Verify the distribution's Core image diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5aa1f5b01..0a307c7ad 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,7 +52,7 @@ jobs: - name: Verify names, documentation and CI selection invariants run: make check-names check-docs check-ci - # Parts run in parallel; the serial store integration package is split into deterministic shards. + # Parts run in parallel; the serial integration package is split into deterministic shards. backend: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'backend') @@ -62,11 +62,11 @@ jobs: include: - part: runtime - part: core - - part: store + - part: integration shard: 1/3 - - part: store + - part: integration shard: 2/3 - - part: store + - part: integration shard: 3/3 name: backend (${{ matrix.part }}${{ matrix.shard && format(' {0}', matrix.shard) || '' }}) runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} @@ -138,9 +138,9 @@ jobs: - name: Build standalone Core and test services and clients if: matrix.part == 'core' run: make build-core check-core-packages - - name: Test Core persistence shard ${{ matrix.shard }} - if: matrix.part == 'store' - run: make check-core-store OAC_CORE_STORE_SHARD=${{ matrix.shard }} + - name: Test Core integration shard ${{ matrix.shard }} + if: matrix.part == 'integration' + run: make check-core-integration OAC_CORE_INTEGRATION_SHARD=${{ matrix.shard }} distribution: needs: plan @@ -184,8 +184,18 @@ jobs: compose: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'compose') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + architecture: amd64 + - runner: ubuntu-24.04-arm + architecture: arm64 + runs-on: ${{ matrix.runner }} + env: + GOARCH: ${{ matrix.architecture }} + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: @@ -193,6 +203,11 @@ jobs: - uses: actions/setup-go@v7 with: go-version-file: go.mod + - name: Test the native Core installation lifecycle + run: go test ./services/core/cmd/oac -count=1 -timeout=3m + - uses: ./.github/actions/e2b-provider + - name: Build and verify the architecture-matched E2B helper + run: bash scripts/build-e2b-provider.sh - name: Allocate an isolated Compose project run: python3 -c 'import uuid; print("COMPOSE_SMOKE_PROJECT=oac-smoke-" + uuid.uuid4().hex)' >> "$GITHUB_ENV" - name: Build the images, start the installation and verify it diff --git a/.github/workflows/ci-review.yml b/.github/workflows/ci-review.yml index 0cd33e664..2bc1069bd 100644 --- a/.github/workflows/ci-review.yml +++ b/.github/workflows/ci-review.yml @@ -16,12 +16,28 @@ permissions: jobs: review: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - kind: code + name: Code review + instructions: | + 检查本次代码改动的正确性、仓库设计规则及已有 CI 结果。先读取 AGENTS.md 和 CONTRIBUTING.md,再查看相关实现和测试。 + 用 gh pr checks 查询这个 PR 已有的检查结果,必要时读取 Actions 日志。检查失败、缺失或尚未完成时如实报告,不把合并当作检查通过的证据。 + 报告包括实际行为变化(1–3 条)、代码审查结论和 CI 状态。全部正常时保持简短;有问题时给出文件与行号、证据和最小修改建议。 + - kind: docs + name: Docs review + instructions: | + 每次都检查文档与本次代码变化是否一致,即使 PR 没改文档。读取相关实现、测试和完整文档,核对平台、命令、配置、路径、默认值、接口和操作流程,找出遗漏更新或已经过时的说明。 + 文档有修改(包括新增、删除和重命名)时,再检查文档内部及同主题文档间的矛盾、重复维护的事实、主题归属和中英文含义。按 AGENTS.md 和 CONTRIBUTING.md 的 Documentation ownership 表判断位置。必要的概述加链接、中英文对照和同一来源生成的文档不算重复。 + 分别报告“文档与代码一致性”和“文档矛盾、重复与归属”。未改文档时,第二项写“本次未修改文档”。只报告本次修改引入或使之过时的、有依据的问题,给出文件与行号、对应代码或文档依据,以及最小修改建议。测试或链接检查通过不代表语义一致。只报告影响读者理解或操作的具体问题,不把未穷举实现细节当作文档缺陷。 if: github.event.pull_request.merged == true runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 30 env: - # allowed_non_write_users turns on a subprocess secret scrub; opt out so Claude's - # commands can read GH_TOKEN and the Feishu webhook. + # Keep GH_TOKEN available to read the PR and Actions logs. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '0' steps: # The triggering revision is already on main, so its rules and code are trusted. @@ -30,14 +46,15 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 2 persist-credentials: false - - name: Review and send Feishu card with Claude Code - # Notification is best effort: a failed review or delivery never fails the job. + - name: Prepare the report schema + id: schema + run: python3 scripts/ci_review.py schema "${{ matrix.kind }}" >> "$GITHUB_OUTPUT" + - name: Review with Claude Code + id: llm continue-on-error: true timeout-minutes: 25 uses: anthropics/claude-code-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1 env: - FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} - FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} ANTHROPIC_BASE_URL: https://api.minimax.cn/anthropic ANTHROPIC_AUTH_TOKEN: ${{ secrets.MINIMAX_API_KEY }} CLAUDE_CODE_AUTO_COMPACT_WINDOW: '524288' @@ -54,29 +71,57 @@ jobs: allowed_bots: '*' allowed_non_write_users: '*' prompt: | - 你是 CI 的负责人,负责审核 CI 结果并给出结论。 - 仓库 ${{ github.repository }} 的 PR #${{ github.event.pull_request.number }} 已合入 main: - - PR:https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }} - - 合并 commit:${{ github.event.pull_request.merge_commit_sha }}(已 checkout 到当前目录) - - 用 gh pr checks ${{ github.event.pull_request.number }} --repo ${{ github.repository }} 查询这个 PR 已有的检查结果,必要时读取对应 Actions 日志。不要触发新的 CI,也不要把合并本身当作检查通过的证据;管理员可能绕过门禁。检查失败、缺失或尚未完成时如实报告。 + 你负责本次 PR 的 ${{ matrix.name }},在独立上下文中完成审查。 + 仓库:${{ github.repository }};PR:#${{ github.event.pull_request.number }}。 + 合并提交:${{ github.event.pull_request.merge_commit_sha }},已检出到当前目录。 + 先用 gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} 读取变更清单与完整差异,再检查相关文件。 - 任务:给飞书群发一张中文卡片(Card 2.0)总结这次 CI,尽量在 10 轮以内给出结论,工具调用尽可能的并行。 - 环境里有 gh(已登录,GH_TOKEN)、git、node 和完整的仓库代码。 + ${{ matrix.instructions }} - 卡片要让手机上的读者快速看懂,如果一切正常,表达的尽可能简单,如果没有问题,尽量简洁: - 1. 哪个 PR(github id + PR 标题 + 链接) - - 如:`github id: jing332, PR 标题: 添加一个新功能, 链接: https://github.com/org/repo/pull/123` - 2. 改了什么(实际行为变化,1–3 条) - - 如:`添加了一个新功能、修改了 web 和 backend 的 CI` - 3. 是否符合仓库规则(AGENTS.md、CONTRIBUTING.md 及相关文档):未发现明确违规 / 发现需修复问题 / 信息不足,无法确认 - - 如:`符合仓库规则` - 4. CI 哪里失败、可能的原因(可以看日志),全部通过就直接说通过,不要给出细节。如果 CI 失败,则详细说明。 - - 如:`CI 失败,具体是哪几个失败;全部通过 ✅` - - 发送:webhook 地址在环境变量 FEISHU_WEBHOOK_URL。 - 响应 code=0 才算成功,失败就排查并重试,直到发出去。最后用一句中文说明结果,如:`CI 结果已发送至飞书群`。 - 不要在输出里打印 webhook 地址和密钥。 + 按 JSON schema 返回中文报告。status 为 ok(未发现问题)、issues(发现有证据的问题)或 incomplete(审查失败、范围未检查完整或检查结果尚未完成)。有问题且仍有未检查项时,用 issues 并在对应字段中说明缺项。按 schema 把各项结论分别填入字段,不要重复标题;changes 用 1–3 条 Markdown 列表,其他字段无问题时一句话,有问题时保留依据和建议,遵守各字段长度限制。 + 围绕本次 diff 和受影响的文档展开,证据充分后输出结果,避免重复核对同一结论。 + 只读审查,不修改仓库,不触发新的 CI,不发送消息。两份报告会由后续 job 合并发送。 claude_args: >- - --allowedTools Bash Read Write Edit Glob Grep WebFetch WebSearch - --max-turns 30 + --allowedTools Bash Read Glob Grep + --json-schema '${{ steps.schema.outputs.schema }}' + - name: Save the review result + if: always() + env: + REVIEW_OUTCOME: ${{ steps.llm.outcome }} + REVIEW_RESULT: ${{ steps.llm.outputs.structured_output }} + run: python3 scripts/ci_review.py collect "${{ matrix.kind }}" "$RUNNER_TEMP/review-${{ matrix.kind }}.json" + - uses: actions/upload-artifact@v6 + if: always() + with: + name: ci-review-${{ matrix.kind }} + path: ${{ runner.temp }}/review-${{ matrix.kind }}.json + if-no-files-found: error + overwrite: true + retention-days: 7 + + notify: + name: Combined Feishu notification + needs: review + if: always() && github.event.pull_request.merged == true + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} + timeout-minutes: 5 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + persist-credentials: false + - uses: actions/download-artifact@v6 + continue-on-error: true + with: + pattern: ci-review-* + merge-multiple: true + path: ${{ runner.temp }}/reviews + - name: Send one card with both results + continue-on-error: true + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} + run: python3 scripts/ci_review.py notify "$RUNNER_TEMP/reviews" diff --git a/.github/workflows/e2b-template.yml b/.github/workflows/e2b-template.yml index 44fce7615..9a0100767 100644 --- a/.github/workflows/e2b-template.yml +++ b/.github/workflows/e2b-template.yml @@ -125,7 +125,6 @@ jobs: revision="$(git rev-parse HEAD)" for harness in codex claude mcode; do builder="scripts/build-$harness-runtime.sh" - if [[ "$harness" == codex ]]; then builder=scripts/build-agents-runtime.sh; fi AGENTS_RUNTIME_BUILD_DIR="$output_root/$harness" bash "$builder" docker build --platform linux/amd64 --tag "oac-e2b-$harness:$revision" "$output_root/$harness" image="$(docker image inspect --format '{{.Id}}' "oac-e2b-$harness:$revision")" diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 7be750d35..c901adc6f 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -63,6 +63,14 @@ jobs: run: | go build -ldflags "-X github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/cli.Version=$(git rev-parse HEAD)" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/daemon/cmd/oac-daemon node --test scripts/build-native-installer.test.mjs + - name: Build and test the shared Core installer + run: | + go build -o "$RUNNER_TEMP/oac${{ runner.os == 'Windows' && '.exe' || '' }}" ./services/core/cmd/oac + go test ./services/core/cmd/oac -count=1 -timeout=3m + - name: Exercise the Windows Core launcher + if: runner.os == 'Windows' + shell: pwsh + run: ./deploy/test_install.ps1 -Binary "$env:RUNNER_TEMP/oac.exe" - name: Verify native download bootstrap and recovery run: go test ./services/core/internal/nativeinstaller -count=1 -timeout=3m - name: Native filesystem, authentication and process lifecycle @@ -163,3 +171,17 @@ jobs: path: ${{ runner.temp }}/native-ci-diagnostics/ retention-days: 7 if-no-files-found: ignore + + core-intel-mac: + name: Core installer (macOS Intel) + runs-on: macos-15-intel + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - run: go test ./services/core/cmd/oac -count=1 -timeout=3m + - run: go build -o "$RUNNER_TEMP/oac" ./services/core/cmd/oac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72bcd1f5b..c8ff4ab28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,6 +114,9 @@ jobs: path: ${{ runner.temp }}/native-artifacts - name: Assemble the native installation catalog run: node scripts/build-native-catalog.mjs "$RUNNER_TEMP/native-artifacts" "$RUNNER_TEMP/native-installers" + - uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 - uses: ./.github/actions/e2b-provider - name: Build matched artifacts env: @@ -124,17 +127,17 @@ 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" for asset in "$HOME/.oac/build/core-distribution/"*; do if [[ -f "$asset" ]]; then ln "$asset" "$HOME/.oac/build/release-upload/"; fi done - cp deploy/install.sh "$HOME/.oac/build/release-upload/install.sh" - (cd "$HOME/.oac/build/release-upload" && sha256sum install.sh > install.sh.sha256) + cp deploy/install.sh deploy/install.ps1 "$HOME/.oac/build/release-upload/" + (cd "$HOME/.oac/build/release-upload" && sha256sum install.sh > install.sh.sha256 && sha256sum install.ps1 > install.ps1.sha256) - name: Sign in to GHCR if: github.event_name == 'push' || inputs.draft_release env: diff --git a/AGENTS.md b/AGENTS.md index deb3b2925..f9023c9d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,13 @@ OpenAgentCore is protocol-first and modular. Core orchestrates operations that p OpenAgentCore is infrastructure. Change a boundary only when the existing protocol cannot express the behavior, and make that the smallest change that leaves the design intact. Hold the code to the standard of a careful, widely used open-source service. -Keep it concise. Write elegant code that reuses existing code and standard SDKs as far as possible, and avoid redundant code. Expose nothing that does not need to be exposed: no port, route, command or setting without a caller. +### Simplicity and performance + +Do not multiply entities without necessity. The long-term goal is minimal code, dependencies and runtime overhead while preserving protocol correctness, isolation and clear ownership. + +- Before adding an abstraction, package, dependency, service, execution path or setting, show why existing code and maintained standard SDKs cannot meet a current requirement cleanly. Prefer reuse, simplification and deletion; do not build extension points for hypothetical needs. Expose no port, route, command or setting without a caller. +- Keep cohesive responsibilities together and remove redundant layers and indirection. Split code at a real responsibility or protocol boundary, not to meet a file-size target. Fewer files or packages alone do not establish simplicity. +- Justify performance optimizations with reproducible measurements against a baseline under representative workloads. Compare the relevant latency, throughput, allocations, memory use, startup time or distribution size, and account for added complexity and resource costs. Keep the simplest implementation that meets the measured requirement. ### Protocols at every boundary @@ -19,9 +25,10 @@ Keep it concise. Write elegant code that reuses existing code and standard SDKs | Boundary | Protocol code | Protocol doc | | --- | --- | --- | -| Application–Core (`/v1`) | Types in `contracts/agents-api/v1/` and route annotations in `services/core/internal/api/`; `make openapi` generates `contracts/agents-api/openapi.yaml` | [Agents API guide](docs/api/public-agent-api.md) | +| Application–Core (`/v1`) | Official schema pinned by `contracts/agents-api/upstream.json` plus Go-owned `x_agents_core` extensions; `make openapi` generates public Go types and `contracts/agents-api/openapi.yaml` | [Agents API guide](docs/api/public-agent-api.md) | | Web and operators–Core (`/core/v1`) | Route annotations in `services/core/internal/api/`; `make openapi` generates `contracts/agents-api/core.openapi.yaml` | [Core administration API](contracts/agents-api/admin-api.md) | | Nodes and daemons–Core (`/api/v1` HTTP routes; the node and daemon wire protocols are separate rows) | Route annotations in `services/core/internal/api/`; `make openapi` generates `contracts/agents-api/runtime.openapi.yaml` | [Machine connection API](contracts/agents-api/machine-api.md) | +| Core and sandbox nodes–Workspace filesystem | `services/core/internal/workspacefs/workspacefs.go` | [Workspace filesystem providers](docs/workspace-provider.md) | | Core–Sandbox Provider | `services/core/internal/sandbox/sandbox_provider.go` | [Sandbox Provider guide](docs/sandbox-provider.md) | | Core–sandbox node | `services/core/internal/sandbox/node/wire.go` | [Sandbox node protocol](contracts/agents-api/node-generation-protocol.md) | | Provider–Runtime startup | `internal/runtimebootstrap/bootstrap.go` | [Runtime bootstrap](docs/runtime-bootstrap.md) | @@ -33,7 +40,7 @@ Keep it concise. Write elegant code that reuses existing code and standard SDKs - A new Sandbox Provider, Harness, model provider or vendor feature changes only its adapter. It adds no Core execution path, store table or column, migration, deployment or configuration field, API field or Web UI specific to one vendor or Harness. The [Sandbox Provider guide](docs/sandbox-provider.md) and [Harness onboarding](contracts/agents-api/harness-onboarding.md) describe how to add an adapter. - When the protocol cannot express what an adapter needs, change the protocol. Never add an optional side interface for one implementation. -- Implementing the declared `CheckpointProvider` lifecycle in one vendor's Provider is an adapter change. A vendor-only pause interface, a Core path for that vendor, vendor receipts in the store or a vendor idle setting in the deployment is not. +- Implementing the declared checkpoint lifecycle in one vendor's Provider is an adapter change. A vendor-only pause interface, a Core path for that vendor, vendor receipts in the store or a vendor idle setting in the deployment is not. - Fix shared lifecycle, admission, cancellation, reuse and performance problems in the common flow, never in a branch selected by Harness, Runtime or vendor name. Core preparation and execution never branch on operating system or Environment source; platform support requires native CI builds and automated tests. - Each Harness runs its own model and tool loop through a maintained upstream SDK or native protocol, in the Environment's declared workspace directory. Its native history or configuration directory is never the workspace. Never build a second executor, a hand-written model/tool loop or a compatibility framework to fabricate parity. The public API and persistence never depend on one engine's native item types. @@ -47,7 +54,7 @@ Keep it concise. Write elegant code that reuses existing code and standard SDKs Each setting and each piece of data is written in one place and read from that place: no second copy, no environment-variable or file fallback and no alias. A new setting joins its category and lives beside its peers. -The categories are [process settings](docs/configuration.md#process-settings-configjson), [derived files](docs/configuration.md#how-oac-apply-works), [secrets](docs/configuration.md#installation-directory), and Core's database for [runtime settings](docs/configuration.md#runtime-settings-web) and execution data. [Configuration](docs/configuration.md) owns the installation layout and the settings themselves. +The categories are [process settings](docs/configuration.md#process-settings), [secrets](docs/configuration.md#compose-installations), and Core's database for [runtime settings](docs/configuration.md#runtime-settings-web) and execution data. [Configuration](docs/configuration.md) owns the installation layout and the settings themselves. ### Pre-release: no compatibility layers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5710c3f55..7973a5e9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ This guide owns how to work in the repository: documentation ownership, the repo | --- | --- | | Design principles, public API fidelity, settings and data ownership, documentation rules | [AGENTS.md](AGENTS.md) | | Protocol code and documents at each component boundary | [Protocol map](AGENTS.md#protocols-at-every-boundary) | -| Projects, keys, resource isolation, administrator authority, secrets and audit concepts | [Concepts and ownership](docs/concepts.md) | +| Projects, keys, resource isolation, administrator authority, secrets and audit concepts | [Concepts](docs/concepts.md) | | Component responsibilities and Session flow | [Architecture](docs/architecture.md) | | Developer setup, repository map and focused checks | [Develop OpenAgentCore](docs/development.md) | | API callers, credentials and route inventory | [API index](docs/api/index.md) | @@ -21,6 +21,7 @@ This guide owns how to work in the repository: documentation ownership, the repo | Harness registration, service qualification and acceptance | [Harness onboarding](contracts/agents-api/harness-onboarding.md) | | Harness capabilities by placement | [Harness capabilities](contracts/agents-api/harness-capabilities.md) | | Harness selection, model providers and native parameters | [Model execution](contracts/agents-api/model-execution.md) | +| Independent workspace filesystem protocol and adapter behavior | [Workspace filesystem providers](docs/workspace-provider.md) | | Provider registration and lifecycle | [Sandbox Provider guide](docs/sandbox-provider.md) | | Sandbox deployment, selection and administrative transitions | [Sandbox deployment](contracts/agents-api/sandbox-deployment.md) | | Operator node tasks | [Nodes guide](docs/getting-started/nodes.md) | @@ -52,7 +53,6 @@ Core must build, deploy and run independently of product services, frontends and - Parsar owns users, workspaces, business authorization, Agent/Team definitions, capabilities, product conversations, IM/sharing, approval decisions and billing. It uses Core for execution. - A product conversation may reference several execution Sessions. Core owns native engine session identities; an execution Session has its own lifetime, separate from a daemon connection, process or sandbox. - Build application orchestration on the [public Session and event contract](docs/api/public-agent-api.md). Product cursor replay must be an explicit product extension. Business Team orchestration belongs to the application; Core's pinned `multi_agent` and Subagent resources remain part of the public contract. -- Daemon Skill/SP authoring is a product operation: forward it through a scoped product callback that checks the original requester and workspace. A Runtime credential alone must not authorize business writes. ### Optional application example @@ -78,6 +78,7 @@ Record unrelated findings without starting them. Scope compatibility claims to t ### Implementation conventions +- Apply the [simplicity and performance principles](AGENTS.md#simplicity-and-performance) when adding structure or optimizing execution. - Keep one formatter, parser, validator and error mapper per job, and one error mapper per API surface. - Share frontend formatting and labels in `apps/web/src/lib/`. - Use `internal/obs/log` for logs. Keep credentials out of source and logs. Harness profiles must not copy Runtime tool environment values; see the [environment contract](contracts/agents-api/environments.md#explicit-local-tool-environment). @@ -111,11 +112,11 @@ The [CI selection policy](docs/maintainers.md#continuous-integration) names affe | `OAC_TEST_DATABASE_URL` | A dedicated test database. The full gate fails when it is missing. | | `OAC_TEST_OFFICIAL_SDK_PYTHON` | The pinned official SDK interpreter | -The role needs `CREATE DATABASE`: tests of database-wide state, such as the execution lease and the provider identity, create and drop isolated `oac_*_tests` databases. Tests must not bypass the production provider-switch guard. +The role needs `CREATE DATABASE`: tests of database-wide state, such as the execution lease and the provider identity, create and drop isolated `oac_*_tests` databases. They copy them from a migrated template, the test database's name with `_template` before `_tests`, which stays beside it. Tests must not bypass the production provider-switch guard. ### Contract and schema rules -- `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. +- `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference, and projects the model-provider protocol names of `internal/modelprovider/config.go` to the client; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. - `make sqlc-generate` owns only `services/core/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. - `make check-runtime-contract` is the focused Core–Runtime contract entry point; see [Contract verification](docs/runtime-protocol.md#contract-verification). It also runs through `check-go` and `check-core`. @@ -142,7 +143,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 c5c4f5907..5a93f513b 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-core check-core check-core-packages check-core-store 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-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 check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' @@ -32,21 +32,28 @@ sqlc-generate: SWAG ?= go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) -.PHONY: openapi +.PHONY: openapi check-openapi +OPENAPI_FLAGS ?= +check-openapi: + $(MAKE) openapi OPENAPI_FLAGS=--check + python3 scripts/generate-public-api.test.py + openapi: @set -e; root="$${OAC_DEV_HOME:-$$HOME/.oac}/build"; mkdir -p "$$root"; \ output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ + python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --swag-roots "$$output/roots.go"; \ $(SWAG) init \ - -g cmd/server/main.go --dir ./services/core,./contracts/agents-api/v1 \ + -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,./internal/modelprovider,$$output" \ --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ - go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml + go run ./scripts/openapi-split $(OPENAPI_FLAGS) "$$output/swagger.yaml" "$$output/extensions.json" "$$output/core.json" contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml; \ + python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --extensions "$$output/extensions.json" --core "$$output/core.json" check-sqlc: python3 scripts/check-sqlc.py -check-go: +check-go: check-openapi go test ./apps/daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1 .PHONY: check-runtime-contract @@ -64,23 +71,23 @@ build-daemon: build-core: ./scripts/build-core.sh -CORE_STORE_PACKAGE := github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store +CORE_INTEGRATION_PACKAGE := github.com/MiniMax-AI/OpenAgentCore/services/core/tests/integration -check-core: override OAC_CORE_STORE_SHARD := -check-core: build-core check-core-packages check-core-store +check-core: override OAC_CORE_INTEGRATION_SHARD := +check-core: build-core check-core-packages check-core-integration -# Core, service and client tests except the serial store integration package. +# Core, service and client tests except the serial integration package. check-core-packages: @set -euo pipefail; packages=$$(go list ./services/core/... ./packages/agents-client/...); \ - packages=$$(printf '%s\n' "$$packages" | grep -vxF '$(CORE_STORE_PACKAGE)'); \ + packages=$$(printf '%s\n' "$$packages" | grep -vxF '$(CORE_INTEGRATION_PACKAGE)'); \ go test $$packages -count=1 -timeout=20m PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s services/core/tests -p 'official_diagnostics_test.py' PYTHONDONTWRITEBYTECODE=1 python3 services/core/deploy/e2b/managed_init_test.py -# Store integration tests run serially and include bounded lifecycle waits that together exceed Go's 10m default. -# OAC_CORE_STORE_SHARD=INDEX/TOTAL runs one deterministic partition; unset runs them all. -check-core-store: - PYTHONDONTWRITEBYTECODE=1 python3 scripts/go-test-shard.py $(CORE_STORE_PACKAGE) $(or $(OAC_CORE_STORE_SHARD),1/1) -count=1 -timeout=20m +# Integration tests run serially and include bounded lifecycle waits that together exceed Go's 10m default. +# OAC_CORE_INTEGRATION_SHARD=INDEX/TOTAL runs one deterministic partition; unset runs them all. +check-core-integration: + PYTHONDONTWRITEBYTECODE=1 python3 scripts/go-test-shard.py $(CORE_INTEGRATION_PACKAGE) $(or $(OAC_CORE_INTEGRATION_SHARD),1/1) -count=1 -timeout=20m # The distribution's Core image, without the native installer catalog. docker-build-core: @@ -145,8 +152,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 @@ -177,7 +184,7 @@ check-microsandbox-provider: .PHONY: check-distribution build-core-distribution check-distribution: node --test scripts/build-native-catalog.test.mjs - go test ./services/web -count=1 + go test ./services/web ./services/core/cmd/oac -count=1 PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/node -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/compose -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/acceptance -p 'test_*.py' diff --git a/README.md b/README.md index 6277909fd..d8056f965 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 @@ -42,31 +42,37 @@ Core keeps durable execution state. The Runtime runs the chosen harness inside t ## Install -On a Linux amd64 host with Docker and Python 3.9+: +On Linux or macOS with [Docker configured](https://openagentcore.dev/docs/getting-started/install#prerequisites): ```sh curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash ``` +Windows PowerShell: + +```powershell +irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex +``` + 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..fb55a62a5 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)接入。 ## 界面预览 @@ -42,31 +42,37 @@ OpenAgentCore 在你自己的基础设施上运行 AI Agent,对外提供 [Open ## 安装 -在已准备 Docker 和 Python 3.9+ 的 Linux amd64 主机上: +在已按[前置条件](https://openagentcore.dev/zh/docs/getting-started/install#prerequisites)准备 Docker 的 Linux 或 macOS 上: ```sh curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash ``` +Windows PowerShell: + +```powershell +irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex +``` + 然后: 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 368943517..464f72f9d 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 a16a9d7ce..4cbab1ca5 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -65,8 +65,8 @@ 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"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."}} - running, err := NewFactory(config)(ctx, request, out) + 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"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."}} + running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_test.go index e9d4d2eb2..2ef7efc62 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -27,7 +27,7 @@ func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { defer cancel() // A stopped consumer must not prevent native output draining or cancellation. out := make(chan proto.Envelope) - running, err := NewFactory(config)(ctx, cancellationRequest(), out) + running, err := startSingleTurn(ctx, config, cancellationRequest(), out) if err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 8) - running, err := NewFactory(cancellationConfig(root, mode))(ctx, cancellationRequest(), out) + running, err := startSingleTurn(ctx, cancellationConfig(root, mode), cancellationRequest(), out) if err != nil { t.Fatal(err) } @@ -125,7 +125,7 @@ func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, cancellationRequest(), out) + running, err := startSingleTurn(ctx, config, cancellationRequest(), 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 de9454556..b19a7584e 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 := NewPreparationFactory(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(); 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 := NewPreparationFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer resource.Close() - 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.Start(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,9 +75,9 @@ 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) + s, err := startSingleTurn(ctx, config, req, out) if err != nil { t.Fatal(err) } 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/contracts.go b/apps/daemon/internal/agent/claudesdk/contracts.go index 4683fd3aa..3d6be7a98 100644 --- a/apps/daemon/internal/agent/claudesdk/contracts.go +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -11,17 +11,10 @@ var ( _ agent.DurableSteerer = (*session)(nil) _ agent.Steerer = (*session)(nil) _ agent.FunctionResultSubmitter = (*session)(nil) - _ agent.PermissionResponder = (*session)(nil) - _ agent.UserChoiceResponder = (*session)(nil) _ agent.WorkspaceReader = (*session)(nil) _ agent.WorkspaceDirectoryLister = (*session)(nil) _ agent.WorkspaceWriter = (*session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) _ agent.WorkspaceReader = (*executor)(nil) _ agent.WorkspaceDirectoryLister = (*executor)(nil) _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) ) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index e99650049..d422248fa 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -22,11 +22,9 @@ const claudeSDKNodeEnv = "OAC_RUNTIME_CLAUDE_SDK_NODE" var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilityUnsupported, Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilityUnsupported, Usage: proto.CapabilitySupported, Resume: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, Steering: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, @@ -60,9 +58,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if entrypoint == "" { return nil } - out := &agent.Runtime{Info: descriptor, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, fmt.Errorf("claude_sdk: configured runtime is unavailable") - }} + out := &agent.Runtime{Info: descriptor} var config Config fail := func(err error) *agent.Runtime { @@ -120,8 +116,8 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d } caps := &out.Info.Capabilities caps.EnvironmentNone, caps.FunctionTools = proto.CapabilityUnsupported, proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) - caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported - caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = proto.CapabilitySupported, proto.CapabilitySupported + caps.LocalEnvironment, caps.WorkspaceReadPreparation = proto.CapabilitySupported, proto.CapabilitySupported + caps.NativeSessionRecovery = proto.CapabilitySupported } out.Info.Available, out.Info.Version = true, info.SDK out.Info.Capabilities.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) @@ -142,12 +138,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityUnsupported, proto.CapabilityUnsupported out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityUnsupported } - out.Session = NewFactory(config) out.Executor = NewExecutorFactory(config) - if out.Info.Capabilities.LocalEnvironment.IsSupported() { - out.Preparation = NewPreparationFactory(config) - out.WorkspaceReadPreparation = true - } fmt.Fprintf(options.Stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) return out diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index d62d77781..9181b5454 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -116,21 +116,15 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { } continue } - if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Preparation != nil { + if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() { t.Fatalf("runtime: %+v", runtime) } registry := agent.NewRegistry() registry.Register(Declaration, *runtime) info := registry.SupportedAgentKinds()[0] - if info.Capabilities.WorkspaceAuthoring.IsSupported() || info.Capabilities.Preparation.IsSupported() != ready { + if info.Capabilities.Preparation.IsSupported() != ready { t.Fatal(info) } - if !ready { - factory, _ := registry.Resolve("claude_sdk") - if _, err := factory(t.Context(), proto.PromptRequestPayload{}, nil); err == nil || !strings.Contains(err.Error(), "runtime is unavailable") { - t.Fatal(err) - } - } } } } diff --git a/apps/daemon/internal/agent/claudesdk/error_classification_test.go b/apps/daemon/internal/agent/claudesdk/error_classification_test.go index 737d3d173..6ebc1eab2 100644 --- a/apps/daemon/internal/agent/claudesdk/error_classification_test.go +++ b/apps/daemon/internal/agent/claudesdk/error_classification_test.go @@ -25,7 +25,7 @@ func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - s, err := NewFactory(config)(ctx, req, out) + s, err := startSingleTurn(ctx, config, req, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 4f79035ce..0381cf750 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -50,7 +50,7 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ExecutionControls: &controls, AgentOptions: map[string]any{"model": "native-model"}} - _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) + _, err := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.Contains(err.Error(), "execution controls require") { t.Fatal("unsupported controls did not fail at admission", err) } @@ -67,7 +67,7 @@ func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input"), MCPHTTPServers: &servers} - _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) + _, err := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.Contains(err.Error(), "service-origin MCP requires a service execution host") { t.Fatal("MCP reached an unsupported environment", err) } diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 34240d0c5..5b99aed4f 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -36,7 +36,7 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { if ctx == nil { ctx = context.Background() } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return nil, errors.New("claudesdk: Executor preparation cannot submit input") } start, env, err := prepareConfiguration(config, req) @@ -91,9 +91,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_fixture_test.go b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go index 55768099f..15d683ccf 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -4,10 +4,32 @@ package claudesdk import ( "bufio" + "context" "encoding/json" "os" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +// startSingleTurn prepares an Executor for one Turn and closes it once that +// Turn settles, so each test observes the complete native lifecycle. +func startSingleTurn(ctx context.Context, config Config, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Turn, error) { + run, input := req.RunID, req.Input + req.RunID, req.Input = "", nil + resource, err := NewExecutorFactory(config)(ctx, req) + if err != nil { + return nil, err + } + turn, err := resource.StartTurn(ctx, run, input, out) + if turn == nil { + _ = resource.Close(context.Background()) + return nil, err + } + go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = resource.Close(context.Background()) }() + return turn, err +} + func helperTurn(scanner *bufio.Scanner, request *startRequest) (func(bridgeEvent), func()) { _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) if !scanner.Scan() { 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 25fb8fcda..669f2dce1 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -80,7 +80,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"}, AgentOptions: map[string]any{"model": model, "system_prompt": "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"}, AgentOptions: map[string]any{"model": model, "system_prompt": "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 09f8b430a..d9c358b83 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -22,11 +22,11 @@ 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{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", ObserveToolObservations: true, AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "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) - running, err := NewFactory(config)(ctx, request, out) + running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go index 335ff6ecd..1f534d1a4 100644 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -137,13 +137,12 @@ 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"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "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"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": "Answer briefly and preserve the exact verification value in the conversation. Use no tools."}} if success != nil { - request.ObserveToolObservations = true request.AgentOptions["system_prompt"] = "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}`)}} } - running, err := NewFactory(config)(ctx, request, out) + running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } @@ -269,7 +268,7 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { proof.SessionID, _ = payload.Metadata[proto.DoneMetaAgentSessionID].(string) } } - // Done reports the Turn outcome; the direct factory closes its Executor + // Done reports the Turn outcome; startSingleTurn closes its Executor // after output settlement. Verify release at that boundary. if _, err := s.AwaitSettlement(ctx); err != nil { t.Fatal(err) @@ -277,7 +276,7 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { select { case <-s.process.Done(): case <-time.After(5 * time.Second): - t.Fatal("direct factory retained its process after settlement") + t.Fatal("single-Turn Executor retained its process after settlement") } if !steeringAt.IsZero() { receipt := <-steeringReply 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_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 7d1220a01..a8ad18877 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/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go index c0591c668..feaf594e7 100644 --- a/apps/daemon/internal/agent/claudesdk/messages_test.go +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -24,7 +24,7 @@ func TestMessageObservations(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, request, out) + running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 5f9bae245..46ffde096 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) { @@ -55,7 +54,7 @@ func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []str } func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { - 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) } @@ -64,9 +63,6 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return startRequest{}, nil, err } start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig) - if req.WorkspaceAuthoring { - return fail("requested capability is not available in the private SDK adapter") - } if err := req.ValidateToolSearch(true); err != nil { return startRequest{}, nil, err } @@ -135,8 +131,6 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR start.Model = value case "system_prompt": start.SystemPrompt = value - default: - return fail("unsupported option: " + name) } } if strings.TrimSpace(start.Model) == "" { diff --git a/apps/daemon/internal/agent/claudesdk/preparation.go b/apps/daemon/internal/agent/claudesdk/preparation.go deleted file mode 100644 index af9df8e01..000000000 --- a/apps/daemon/internal/agent/claudesdk/preparation.go +++ /dev/null @@ -1,116 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// prepared is a single admission for direct adapter callers. It uses the same -// Executor as pooled Runtime execution; workspace reads retain the owner. -type prepared struct { - mu sync.Mutex - executor *executor - session *session - binding *preparedStart - closed bool -} - -type preparedStart struct { - turn agent.Turn - done chan struct{} -} - -func NewPreparationFactory(config Config) agent.PreparationFactory { - factory := NewExecutorFactory(config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if config.Workspace == nil { - return nil, errors.New("claudesdk: workspace preparation requires a bound workspace") - } - resource, err := factory(ctx, req) - if resource == nil { - return nil, err - } - e := resource.(*executor) - return &prepared{executor: e, session: e.base}, err - } -} - -func (p *prepared) Start(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return nil, errors.New("claudesdk: admission is unavailable") - } - binding := &preparedStart{done: make(chan struct{})} - p.binding = binding - p.mu.Unlock() - turn, err := p.executor.StartTurn(ctx, run, input, out) - p.mu.Lock() - binding.turn = turn - if turn == nil { - p.binding = nil - } - close(binding.done) - p.mu.Unlock() - if turn != nil { - go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - if p.binding != nil { - p.mu.Unlock() - return nil - } - p.closed = true - p.mu.Unlock() - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - p.mu.Lock() - p.closed = true - binding := p.binding - p.mu.Unlock() - if binding == nil { - return p.executor.Close(ctx) - } - select { - case <-binding.done: - default: - if err := p.executor.Close(ctx); err != nil { - return err - } - select { - case <-binding.done: - case <-ctx.Done(): - return ctx.Err() - } - } - if binding.turn == nil { - return p.executor.Close(ctx) - } - if err := binding.turn.Cancel(ctx); err != nil { - return err - } - p.executor.retire() - return nil -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - defer p.mu.Unlock() - if p.binding == nil || p.binding.turn == nil { - return proto.DonePayload{} - } - return p.binding.turn.CancellationOutcome() -} diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 224a25feb..081a199b9 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -106,10 +106,6 @@ func runPreparationHelper() { _ = json.Unmarshal(fields["turn_id"], &turnID) emit, finish := helperTurnOutput(scanner, turnID) defer finish() - if mode == "cancellation" { - runCancellationHelper(request, "cancellation-wait", scanner, emit) - return - } if strings.HasPrefix(mode, "commands") { runCommandsHelper(request, mode, scanner, emit) return diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 716797389..5c03d23c5 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -5,12 +5,9 @@ package claudesdk import ( "context" "encoding/json" - "errors" "os" "path/filepath" - "reflect" "strings" - "sync" "syscall" "testing" "time" @@ -24,15 +21,15 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { req := preparationRequest() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() - result := make(chan agent.Prepared, 1) + result := make(chan agent.Executor, 1) failed := make(chan error, 1) go func() { - p, err := NewPreparationFactory(config)(ctx, req) + e, err := NewExecutorFactory(config)(ctx, req) if err != nil { failed <- err return } - result <- p + result <- e }() raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) select { @@ -45,17 +42,17 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { t.Fatal(err) } - var preparedResource agent.Prepared + var resource agent.Executor select { - case preparedResource = <-result: + case resource = <-result: case err := <-failed: t.Fatal(err) case <-ctx.Done(): t.Fatal(ctx.Err()) } - p := preparedResource.(*prepared) - defer p.Cancel(context.Background()) - pid := p.session.process.Cmd.Process.Pid + e := resource.(*executor) + defer e.Close(context.Background()) + pid := e.base.process.Cmd.Process.Pid if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted input") } @@ -68,24 +65,17 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - config.Workspace.Directory = "/changed" req.AgentOptions["model"] = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) operation, stopOperation := context.WithCancel(ctx) - s, err := p.Start(operation, "actual-run", proto.TextInput("hello"), out) + turn, err := e.StartTurn(operation, "actual-run", proto.TextInput("hello"), out) stopOperation() if err != nil { t.Fatal(err) } - if s.(*session).owner != p.executor || s.(*session).process.Cmd.Process.Pid != pid { - t.Fatal("Start replaced the prepared native process") - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - if _, err := p.Start(ctx, "duplicate", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("duplicate Start was accepted") + if turn.(*session).owner != e || turn.(*session).process.Cmd.Process.Pid != pid { + t.Fatal("StartTurn replaced the prepared native process") } var done proto.DonePayload for event := range out { @@ -107,7 +97,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "conversation", "attachments", "authoring", "subagents", "workspace-missing", "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() @@ -116,16 +106,10 @@ 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 "authoring": - req.WorkspaceAuthoring = true case "subagents": req.ObserveSubagentIdentities = true - case "workspace-missing": - config.Workspace = nil case "none": req.DisableExecutionEnvironment = true case "functions": @@ -135,7 +119,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) case "controls": req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} } - if _, err := NewPreparationFactory(config)(t.Context(), req); err == nil { + if _, err := NewExecutorFactory(config)(t.Context(), req); err == nil { t.Fatal("invalid preparation was accepted") } if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { @@ -151,7 +135,7 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { config := preparationFixture(t, mode) owner, stop := context.WithCancel(t.Context()) defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + resource, err := NewExecutorFactory(config)(owner, preparationRequest()) if mode == "history-missing" || mode == "invalid-receipt" { if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { t.Fatal("preparation failure was lost", err) @@ -161,15 +145,15 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { if err != nil { t.Fatal(err) } - p := resource.(*prepared) - defer p.Cancel(context.Background()) + e := resource.(*executor) + defer e.Close(context.Background()) switch mode { case "close": - err = p.Close() + err = e.Close(t.Context()) case "owner-cancel": stop() case "native-exit": - err = p.session.process.Cmd.Process.Signal(syscall.SIGKILL) + err = e.base.process.Cmd.Process.Signal(syscall.SIGKILL) case "invalid-start", "cancelled-start": operation, cancel := context.WithCancel(t.Context()) prompt := "" @@ -177,150 +161,24 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { prompt = "hello" cancel() } - _, startErr := p.Start(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) + _, startErr := e.StartTurn(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) cancel() if startErr == nil { - t.Fatal("invalid or cancelled Start succeeded") + t.Fatal("invalid or cancelled StartTurn succeeded") } - err = p.Close() + err = e.Close(t.Context()) } if err != nil { t.Fatal(err) } select { - case <-p.executor.done: + case <-e.done: case <-time.After(5 * time.Second): t.Fatal("unused process was not settled") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("released preparation was reusable") - } - if got := p.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { - t.Fatal("unstarted process fabricated an execution outcome", got) + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("released Executor was reusable") } }) } } - -func TestPreparedCancellationKeepsOwnershipUntilOutputDrain(t *testing.T) { - config := preparationFixture(t, "cancellation") - owner, stop := context.WithTimeout(t.Context(), 10*time.Second) - defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - out := make(chan proto.Envelope) - operation, stopOperation := context.WithCancel(owner) - if _, err := p.Start(operation, "run", proto.TextInput("hello"), out); err != nil { - t.Fatal(err) - } - stopOperation() - if err := p.Close(); err != nil { - t.Fatal(err) - } - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal("operation cancellation or Close cancelled the Session") - } - short, cancel := context.WithTimeout(owner, 30*time.Millisecond) - err = p.Cancel(short) - cancel() - if !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(p.CancellationOutcome(), proto.DonePayload{}) { - t.Fatal("pending output drain reported settled ownership", err) - } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { - t.Fatal(err) - } - if err := p.Cancel(owner); err != nil { - t.Fatal(err) - } - got := p.CancellationOutcome() - if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil { - t.Fatal("cancellation across transfer lost observed output", got) - } - for range out { - } -} - -func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { - for _, cancelResource := range []bool{false, true} { - for range 6 { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - out := make(chan proto.Envelope, 16) - var running agent.Session - var startErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - running, startErr = p.Start(t.Context(), "run", proto.TextInput("hello"), out) - }() - go func() { - defer wg.Done() - if cancelResource { - _ = p.Cancel(t.Context()) - } else { - _ = p.Close() - } - }() - wg.Wait() - if startErr == nil { - if running == nil { - t.Fatal("successful transfer lost its Session") - } - for range out { - } - } - if err := p.Cancel(t.Context()); err != nil { - // A racing Close can confirm resource cleanup without proving the Turn result. - if closeErr := p.executor.Close(t.Context()); closeErr != nil { - t.Fatal(closeErr) - } - } - select { - case <-p.session.process.Done(): - default: - t.Fatal("race left the owned process alive") - } - } - } -} - -func TestPreparedConcurrentStartTransfersOnlyOnce(t *testing.T) { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - results := make(chan bool, 2) - var wg sync.WaitGroup - for range 2 { - wg.Add(1) - go func() { - defer wg.Done() - out := make(chan proto.Envelope, 16) - _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) - results <- err == nil - if err == nil { - for event := range out { - if event.Type == proto.TypeError { - t.Error("losing Start cancelled the transferred Session") - } - } - } - }() - } - wg.Wait() - if first, second := <-results, <-results; first == second { - t.Fatal("concurrent Start did not produce exactly one owner") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go index 5c9668990..a9f7f8fbf 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -73,16 +73,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 { @@ -114,10 +112,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/readiness_test.go b/apps/daemon/internal/agent/claudesdk/readiness_test.go index 49b0d5260..466d6e38e 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/daemon/internal/agent/claudesdk/readiness_test.go @@ -25,7 +25,7 @@ func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { }} req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} - if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { + if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { t.Fatalf("unqualified runtime executed required MCP: %v", err) } } @@ -46,7 +46,7 @@ func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { } req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} - if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || !strings.Contains(err.Error(), "packaged runtime does not support HTTP MCP") { + if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || !strings.Contains(err.Error(), "packaged runtime does not support HTTP MCP") { t.Fatalf("old runtime was not rejected before execution: %v", err) } } @@ -83,7 +83,7 @@ func TestMCPBearerRejectsAnonymousOnlyRuntimeWithoutProbeSecrets(t *testing.T) { token := "private-fixture-token" req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} - if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { + if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { t.Fatalf("old runtime executed authenticated request or readiness received its secret: %v", err) } } diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index e0b7392de..a574a5009 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -29,7 +29,7 @@ func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, request, out) + running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 311952092..f252ce14f 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -33,27 +33,6 @@ type session struct { outcome proto.DonePayload } -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 - resource, err := prepareExecutor(ctx, req) - if err != nil { - return nil, err - } - turn, err := resource.StartTurn(ctx, run, input, out) - if turn == nil { - _ = resource.Close(context.Background()) - return nil, err - } - // Factory callers own one execution. Both entrypoints use the same - // Executor implementation; Runtime pooling uses NewExecutorFactory. - go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = resource.Close(context.Background()) }() - return turn, err - } -} - type bridgeEvent struct { EngineErrorCode json.RawMessage `json:"engine_error_code"` TurnID string `json:"turn_id"` @@ -86,30 +65,13 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if binary == "" { binary = "node" } - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) if err != nil { return nil, err } return &session{process: process, writeMu: &sync.Mutex{}, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil } -func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) (error, bool) { - for scanner.Scan() { - } - if scanner.Err() != nil { - failure = fmt.Errorf("claudesdk: SDK bridge output read failed") - s.process.Cancel() - } - <-stderrDone - s.stopWorkspaceReads() - s.stopWorkspaceDirectories() - waitErr := s.process.Wait() - if waitErr != nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK process failed") - } - return failure, scanner.Err() == nil && waitErr == nil -} - func bridgeFailure(code string) error { switch code { case "invalid_request", "history_unavailable", "execution_failed", "cancelled": diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index c1808a754..30899cfc7 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -26,7 +26,7 @@ func TestTextFactoryCompletionAndFailures(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - s, err := NewFactory(config)(ctx, request, out) + s, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } @@ -74,7 +74,7 @@ func TestTextFactoryCompletionAndFailures(t *testing.T) { } func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { - for _, kind := range []string{"execution-controls", "tool", "option", "outside"} { + for _, kind := range []string{"execution-controls", "tool", "outside"} { t.Run(kind, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) @@ -85,12 +85,10 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "low"} case "tool": request.FunctionTools = []proto.FunctionTool{{}} - case "option": - request.AgentOptions["allowed_tools"] = "anything" case "outside": config.StateDir = filepath.Dir(root) } - _, err := NewFactory(config)(context.Background(), request, make(chan proto.Envelope, 1)) + _, err := startSingleTurn(context.Background(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { t.Fatalf("expected pre-launch rejection, got %v", err) } diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go index 5f83e13d4..b59f5107f 100644 --- a/apps/daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -31,7 +31,7 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, request, out) + running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/unsupported.go b/apps/daemon/internal/agent/claudesdk/unsupported.go index 091e6d167..154d904a4 100644 --- a/apps/daemon/internal/agent/claudesdk/unsupported.go +++ b/apps/daemon/internal/agent/claudesdk/unsupported.go @@ -2,19 +2,10 @@ package claudesdk import ( "context" - "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *session) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { - return fmt.Errorf("%w: native permission responses are not exposed", agent.ErrUnsupportedOperation) -} - -func (s *session) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { - return fmt.Errorf("%w: native user-choice responses are not exposed", agent.ErrUnsupportedOperation) -} - func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } @@ -22,7 +13,3 @@ func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.Wo func (s *session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/claudesdk/unsupported_test.go b/apps/daemon/internal/agent/claudesdk/unsupported_test.go index 82ee6cae2..7fe6048c5 100644 --- a/apps/daemon/internal/agent/claudesdk/unsupported_test.go +++ b/apps/daemon/internal/agent/claudesdk/unsupported_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Nil concrete receivers prove Unsupported needs no native owner, transport, @@ -25,10 +24,7 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - var turn *session - check(turn.SubmitPermission(ctx, secret, proto.PermissionDecisionPayload{})) - check(turn.SubmitPromptForUserChoice(ctx, secret, proto.PromptForUserChoiceDecisionPayload{})) - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index 6780ef95d..0c3af818f 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -27,7 +27,7 @@ func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - s, err := NewFactory(config)(ctx, request, out) + s, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory.go b/apps/daemon/internal/agent/claudesdk/workspace_directory.go index 5b4150cba..1bde6cbe5 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory.go @@ -39,23 +39,8 @@ type workspaceDirectoryEvent struct { Error string `json:"error"` } -var _ agent.WorkspaceDirectoryLister = (*prepared)(nil) var _ agent.WorkspaceDirectoryLister = (*session)(nil) -func (p *prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceDirectory(ctx, path, maxEntries) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return p.session.awaitWorkspaceDirectory(ctx, read) -} - func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { if s.owner != nil { return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go index 1c052d736..e073cffae 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -84,70 +84,64 @@ func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { } } -func directoryPreparation(t *testing.T, mode string) (*prepared, Config) { +func directoryExecutor(t *testing.T, mode string) (*executor, Config) { t.Helper() config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config + e := resource.(*executor) + t.Cleanup(func() { _ = e.Close(context.Background()) }) + return e, config } -func TestWorkspaceDirectoryPreparedBoundsAndMetadata(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") +func TestWorkspaceDirectoryBoundsAndMetadata(t *testing.T) { + e, _ := directoryExecutor(t, "directory-normal") for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatalf("accepted %q: %v", path, err) } } for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { - if _, err := p.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { t.Fatal(limit, err) } } - result, err := p.ListWorkspaceDirectory(t.Context(), "", 4) + result, err := e.ListWorkspaceDirectory(t.Context(), "", 4) if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { t.Fatal(result, err) } - empty, err := p.ListWorkspaceDirectory(t.Context(), "empty", 4) + empty, err := e.ListWorkspaceDirectory(t.Context(), "empty", 4) if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { t.Fatal(empty, err) } for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { + if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { t.Fatal(path, err) } } - if _, err := p.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { + if _, err := e.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { t.Fatal(err) } } -func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") +func TestWorkspaceDirectoryDetachAndTurnStart(t *testing.T) { + e, config := directoryExecutor(t, "directory-held") ctx, detach := context.WithCancel(t.Context()) defer detach() result := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() + go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) detach() - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { t.Fatal(err) } out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } select { case err := <-result: t.Fatal("caller detach discarded native wait", err) @@ -168,24 +162,24 @@ func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { for _, path := range []string{"uncertain", "wrong-id", "bad-kind", "bad-size", "missing-size", "duplicate", "escape-name", "null", "extra"} { t.Run(path, func(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") - result, err := p.ListWorkspaceDirectory(t.Context(), path, 4) + e, _ := directoryExecutor(t, "directory-normal") + result, err := e.ListWorkspaceDirectory(t.Context(), path, 4) if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { t.Fatal(result, err) } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { t.Fatal(err) } }) } for _, mode := range []string{"directory-held", "directory-exit"} { t.Run(mode, func(t *testing.T) { - p, config := directoryPreparation(t, mode) + e, config := directoryExecutor(t, mode) done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() + go func() { _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) if mode == "directory-held" { - if err := p.Close(); err != nil { + if err := e.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -202,19 +196,19 @@ func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { } func TestWorkspaceDirectoryDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") + e, config := directoryExecutor(t, "directory-held") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() + go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) if err := <-done; err != agent.ErrWorkspaceReadUncertain { t.Fatal(err) } - if p.session.process.Context().Err() == nil { + if e.base.process.Context().Err() == nil { t.Fatal("uncertain deadline returned before owner cancellation") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Turn") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index dfaac5162..861312450 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 "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 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}' @@ -41,7 +41,7 @@ esac t.Fatal("readiness did not receive replacement environment", err) } out := make(chan proto.Envelope, 8) - s, err := NewFactory(config)(t.Context(), workspaceRequest(), out) + s, err := startSingleTurn(t.Context(), config, workspaceRequest(), out) if err != nil { t.Fatal(err) } @@ -59,12 +59,12 @@ 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) } - if _, err := NewFactory(config)(t.Context(), workspaceRequest(), out); err == nil { + if _, err := startSingleTurn(t.Context(), config, workspaceRequest(), out); err == nil { t.Fatal("old packaged bridge accepted workspace execution") } if _, err := os.Stat(filepath.Join(config.StateDir, "unexpected-start")); !os.IsNotExist(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 6a19b6e53..b0143e694 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -21,14 +20,6 @@ import ( // Run only inside a separately qualified outer placement, with its pinned native // dependencies. This fixture does not create isolation or public admission. func TestLiveClaudeWorkspaceFactory(t *testing.T) { - testLiveClaudeWorkspace(t, false) -} - -func TestLiveClaudePreparedWorkspace(t *testing.T) { - testLiveClaudeWorkspace(t, true) -} - -func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { configFile := os.Getenv("OAC_TEST_CLAUDE_WORKSPACE_LIVE_CONFIG") if configFile == "" { t.Skip("requires explicit qualified placement and real provider configuration") @@ -72,20 +63,16 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { heartbeat := filepath.Join(config.Workspace.Directory, "heartbeat.txt") artifact := filepath.Join(config.Workspace.Directory, "value.txt") type evidence struct { - Reads []liveWorkspaceRead `json:"reads,omitempty"` - RunID string `json:"run_id"` - Events []proto.Envelope `json:"events"` - Done proto.DonePayload `json:"done"` - Failure string `json:"failure,omitempty"` - Cancelled bool `json:"cancelled"` - CancelMS int64 `json:"cancel_ms,omitempty"` - BridgePID int `json:"bridge_pid"` - Terminals int `json:"terminals"` - Heartbeats []string `json:"heartbeats,omitempty"` - PreparedPID int `json:"prepared_pid,omitempty"` - NativeBefore string `json:"native_before,omitempty"` - NativeAfter string `json:"native_after,omitempty"` - StartContextCancelled bool `json:"start_context_cancelled,omitempty"` + Reads []liveWorkspaceRead `json:"reads,omitempty"` + RunID string `json:"run_id"` + Events []proto.Envelope `json:"events"` + Done proto.DonePayload `json:"done"` + Failure string `json:"failure,omitempty"` + Cancelled bool `json:"cancelled"` + CancelMS int64 `json:"cancel_ms,omitempty"` + BridgePID int `json:"bridge_pid"` + Terminals int `json:"terminals"` + Heartbeats []string `json:"heartbeats,omitempty"` } writeEvidence := func(name string, proof evidence) { t.Helper() @@ -104,42 +91,10 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { 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.AgentOptions = map[string]any{"model": "MiniMax-M3", "system_prompt": "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} - var running agent.Session - var owner agent.PreparedCancellation - if explicitPreparation { - preparation := req - preparation.RunID, preparation.Input = "", nil - var resource agent.Prepared - resource, err = NewPreparationFactory(config)(ctx, preparation) - if err == nil { - defer resource.Close() - owner = resource.(agent.PreparedCancellation) - proof.PreparedPID = resource.(*prepared).session.process.Cmd.Process.Pid - proof.NativeBefore = liveWorkspaceNativeIdentity(t, proof.PreparedPID) - before, _ := os.ReadFile(artifact) - time.Sleep(500 * time.Millisecond) - after, _ := os.ReadFile(artifact) - if !bytes.Equal(before, after) || liveWorkspaceNativeIdentity(t, proof.PreparedPID) != proof.NativeBefore || len(out) != 0 { - t.Fatal("prepared resource changed before initial input") - } - proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, resource.(agent.WorkspaceReader), config.Workspace.Directory, "prepared", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - operation, stopOperation := context.WithCancel(ctx) - running, err = resource.Start(operation, req.RunID, req.Input, out) - stopOperation() - proof.StartContextCancelled = true - if err == nil { - proof.NativeAfter = liveWorkspaceNativeIdentity(t, proof.PreparedPID) - if proof.NativeBefore != proof.NativeAfter || resource.Close() != nil { - t.Fatal("Start replaced the native process or Close affected its transfer") - } - } - } - } else { - running, err = NewFactory(config)(ctx, req, out) - } + running, err := startSingleTurn(ctx, config, req, out) if err != nil { if name == "missing-history" && running == nil && strings.Contains(err.Error(), "history_unavailable") { proof.Failure = err.Error() @@ -152,13 +107,6 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { defer s.Cancel(context.Background()) proof.BridgePID = s.process.Cmd.Process.Pid proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "active", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - if explicitPreparation && proof.BridgePID != proof.PreparedPID { - t.Fatal("Start replaced the prepared bridge") - } - cancelOwned, outcome := s.Cancel, s.CancellationOutcome - if owner != nil { - cancelOwned, outcome = owner.Cancel, owner.CancellationOutcome - } ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() for out != nil { @@ -170,7 +118,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { if cancelOnEffect && !proof.Cancelled && len(value) > 0 && string(value) != "0" && string(value) != "1" { proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "effect", "value.txt")...) started := time.Now() - if err := cancelOwned(ctx); err != nil { + if err := s.Cancel(ctx); err != nil { t.Fatal("factory cancellation failed", err) } proof.CancelMS = time.Since(started).Milliseconds() @@ -201,7 +149,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { if len(a) == 0 || !bytes.Equal(a, b) { t.Fatal("native command effects continued after Cancel") } - settled, _ := json.Marshal(outcome()) + settled, _ := json.Marshal(s.CancellationOutcome()) done, _ := json.Marshal(proof.Done) if !bytes.Equal(settled, done) { t.Fatal("cancellation outcome differs from terminal Done") @@ -278,18 +226,3 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { t.Fatal(err) } } - -func liveWorkspaceNativeIdentity(t *testing.T, bridgePID int) string { - t.Helper() - raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", bridgePID, bridgePID)) - children := strings.Fields(string(raw)) - if err != nil || len(children) != 1 { - t.Fatal("expected exactly one retained native child", err) - } - status, err := os.ReadFile("/proc/" + children[0] + "/stat") - fields := strings.Fields(string(status)[strings.LastIndex(string(status), ")")+1:]) - if err != nil || len(fields) < 20 { - t.Fatal("native process identity unavailable", err) - } - return children[0] + ":" + fields[19] -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read.go b/apps/daemon/internal/agent/claudesdk/workspace_read.go index 9d8f4a568..ec5ea4a56 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_read.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read.go @@ -39,23 +39,8 @@ type workspaceReadEvent struct { Error string `json:"error"` } -var _ agent.WorkspaceReader = (*prepared)(nil) var _ agent.WorkspaceReader = (*session)(nil) -func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return p.session.awaitWorkspaceRead(ctx, read) -} - func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { if s.owner != nil { return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go index 083c2387a..72d32a943 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go @@ -78,32 +78,32 @@ func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { } } -func readPreparation(t *testing.T, mode string) (*prepared, Config) { +func readExecutor(t *testing.T, mode string) (*executor, Config) { t.Helper() config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config + e := resource.(*executor) + t.Cleanup(func() { _ = e.Close(context.Background()) }) + return e, config } -func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { - p, _ := readPreparation(t, "read-normal") +func TestWorkspaceReadBoundsAndBinary(t *testing.T) { + e, _ := readExecutor(t, "read-normal") for _, path := range []string{"", "/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err := e.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatalf("accepted %q: %v", path, err) } } for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { - if _, err := p.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { t.Fatal(limit, err) } } for _, path := range []string{"file", "prefix", "empty"} { - result, err := p.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) + result, err := e.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) expected := bytes.Repeat([]byte{0, 255, 1, 128}, workspaceReadMaxBytes/4) if path == "empty" { expected = nil @@ -112,36 +112,30 @@ func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { t.Fatal(path, err, len(result.Data), result.Truncated) } } - if _, err := p.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { t.Fatal(err) } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { t.Fatal(err) } } -func TestWorkspaceReadDetachAndTransfer(t *testing.T) { - p, config := readPreparation(t, "read-held") +func TestWorkspaceReadDetachAndTurnStart(t *testing.T) { + e, config := readExecutor(t, "read-held") ctx, detach := context.WithCancel(t.Context()) defer detach() result := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); result <- err }() + go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); result <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) detach() - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { t.Fatal(err) } out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } select { case err := <-result: t.Fatal("caller detach discarded native wait", err) @@ -162,24 +156,24 @@ func TestWorkspaceReadDetachAndTransfer(t *testing.T) { func TestWorkspaceReadUnknownAndRelease(t *testing.T) { for _, path := range []string{"uncertain", "wrong-id", "bad-base64", "oversize", "extra"} { t.Run(path, func(t *testing.T) { - p, _ := readPreparation(t, "read-normal") - result, err := p.ReadWorkspaceFile(t.Context(), path, 4) + e, _ := readExecutor(t, "read-normal") + result, err := e.ReadWorkspaceFile(t.Context(), path, 4) if err != agent.ErrWorkspaceReadUncertain || len(result.Data) != 0 { t.Fatal(result, err) } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { t.Fatal(err) } }) } for _, mode := range []string{"read-held", "read-exit"} { t.Run(mode, func(t *testing.T) { - p, config := readPreparation(t, mode) + e, config := readExecutor(t, mode) done := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() + go func() { _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) if mode == "read-held" { - if err := p.Close(); err != nil { + if err := e.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -196,19 +190,19 @@ func TestWorkspaceReadUnknownAndRelease(t *testing.T) { } func TestWorkspaceReadDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - p, config := readPreparation(t, "read-held") + e, config := readExecutor(t, "read-held") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); done <- err }() + go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) if err := <-done; err != agent.ErrWorkspaceReadUncertain { t.Fatal(err) } - if p.session.process.Context().Err() == nil { + if e.base.process.Context().Err() == nil { t.Fatal("uncertain deadline returned before owner cancellation") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Turn") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index 357eb8127..ec627a49e 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -3,6 +3,7 @@ package claudesdk import ( + "context" "encoding/json" "os" "path/filepath" @@ -20,7 +21,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T req.ObserveMessages = true schema := `{"type":"object","properties":{"n":{"const":9007199254740992}}}` req.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} - p, err := NewPreparationFactory(config)(t.Context(), req) + e, err := NewExecutorFactory(config)(t.Context(), req) if mode == "structured-missing" { if err == nil || !strings.Contains(err.Error(), "workspace structured output") { t.Fatal("unqualified bundle admitted", err) @@ -33,7 +34,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T if err != nil { t.Fatal(err) } - defer p.Close() + defer e.Close(context.Background()) req.ExecutionControls.OutputFormat.Schema[0] = ' ' var frozen startRequest if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")), &frozen); err != nil { @@ -43,7 +44,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T t.Fatal("prepared native schema changed with caller memory") } out := make(chan proto.Envelope, 16) - if _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out); err != nil { + if _, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out); err != nil { t.Fatal(err) } for event := range out { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index b84b767a7..2dab86f61 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -76,7 +76,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "workspace-root", "mcp", "caller-policy", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { + for _, name := range []string{"none", "workspace-root", "mcp", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { t.Run(name, func(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() @@ -88,8 +88,6 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: config.Workspace.ScratchDir} case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - case "caller-policy": - req.AgentOptions["workspace"] = "override" case "relative": config.Workspace.Directory = "relative" case "missing": diff --git a/apps/daemon/internal/agent/clirunner/process.go b/apps/daemon/internal/agent/clirunner/process.go index e693df7f5..a9a4465bd 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" ) @@ -19,8 +17,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 { @@ -29,13 +25,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 } @@ -51,54 +45,9 @@ func Start(opts StartOptions) (*Process, error) { opts.KillTimeout = 3 * time.Second } - 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 { @@ -118,41 +67,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 || p.Cmd == nil { + if p == nil || p.waitProcess == nil { return nil } - if p.waitProcess != nil { - return p.waitProcess() - } - err := p.Cmd.Wait() - p.waitOnce.Do(func() { close(p.done) }) - return err + return p.waitProcess() } func closePipe(p io.Closer) { 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/approval_policy.go b/apps/daemon/internal/agent/codex/approval_policy.go deleted file mode 100644 index 9b084e4da..000000000 --- a/apps/daemon/internal/agent/codex/approval_policy.go +++ /dev/null @@ -1,85 +0,0 @@ -package codex - -import ( - "encoding/json" - "fmt" -) - -// SilentGranularPolicy disables every granular Codex approval gate. -func SilentGranularPolicy() AskForApproval { - g := GranularAskForApproval{} // zero value = every gate false - return AskForApproval{Granular: &g} -} - -// HumanApprovalPolicy lets Codex request sandbox escalation from Core. -func HumanApprovalPolicy() AskForApproval { - return AskForApproval{String: "on-request"} -} - -// IsSilent reports whether p suppresses every approval surface. The -// daemon logs this distinction when it starts a thread. Server-request -// handlers are always registered so explicit policy overrides cannot leave -// Codex waiting on an unhandled request. -// -// A nil pointer is treated as silent — the safe default when callers -// forget to wire a policy. -func IsSilent(p *AskForApproval) bool { - if p == nil { - return true - } - if p.Granular != nil { - g := p.Granular - return !g.SandboxApproval && !g.Rules && !g.SkillApproval && - !g.RequestPermissions && !g.MCPElicitations - } - // Bare string variants ("never" is silent; everything else surfaces). - return p.String == "never" -} - -// MarshalJSON emits codex-rs's discriminated union — either a bare -// string or the granular object. Producing both would deserialise to -// the granular branch on the codex side (it wins precedence in -// codex-rs/protocol.rs AskForApproval), but the wire would be wrong; we -// validate exclusivity here. -func (a AskForApproval) MarshalJSON() ([]byte, error) { - hasString := a.String != "" - hasGranular := a.Granular != nil - if hasString && hasGranular { - return nil, fmt.Errorf("codex: AskForApproval must set exactly one of String or Granular, got both") - } - if hasString { - return json.Marshal(a.String) - } - if hasGranular { - return json.Marshal(struct { - Granular *GranularAskForApproval `json:"granular"` - }{Granular: a.Granular}) - } - // Neither set — preserve the historical zero-value encoding. Production - // plans always set a policy explicitly; an empty marshal would - // produce `null`, which codex-rs rejects. - return json.Marshal(struct { - Granular GranularAskForApproval `json:"granular"` - }{}) -} - -// UnmarshalJSON is the inverse: codex's ThreadStartResult.approvalPolicy -// echoes back as either a string or {"granular":...}. Provided so tests -// can round-trip without surprises; production code only marshals. -func (a *AskForApproval) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err == nil { - a.String = s - a.Granular = nil - return nil - } - var obj struct { - Granular *GranularAskForApproval `json:"granular"` - } - if err := json.Unmarshal(data, &obj); err != nil { - return fmt.Errorf("codex: AskForApproval: %w", err) - } - a.Granular = obj.Granular - a.String = "" - return nil -} diff --git a/apps/daemon/internal/agent/codex/approval_policy_test.go b/apps/daemon/internal/agent/codex/approval_policy_test.go deleted file mode 100644 index 7a98f06cd..000000000 --- a/apps/daemon/internal/agent/codex/approval_policy_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package codex - -import ( - "encoding/json" - "testing" -) - -func TestSilentGranularPolicy_AllFalse(t *testing.T) { - p := SilentGranularPolicy() - if p.Granular == nil { - t.Fatal("SilentGranularPolicy must populate Granular") - } - g := *p.Granular - if g.SandboxApproval || g.Rules || g.SkillApproval || g.RequestPermissions || g.MCPElicitations { - t.Fatalf("silent policy must have every gate false, got %+v", g) - } - if !IsSilent(&p) { - t.Fatal("IsSilent must return true for the silent default") - } -} - -func TestHumanApprovalPolicy_UsesOnRequest(t *testing.T) { - p := HumanApprovalPolicy() - if p.String != "on-request" || p.Granular != nil { - t.Fatalf("HumanApprovalPolicy = %+v, want on-request string policy", p) - } - if IsSilent(&p) { - t.Fatal("HumanApprovalPolicy must surface app-server requests") - } -} - -func TestIsSilent_NilIsSilent(t *testing.T) { - if !IsSilent(nil) { - t.Fatal("nil policy must be treated as silent (safe default)") - } -} - -func TestIsSilent_StringPolicies(t *testing.T) { - cases := []struct { - name string - policy AskForApproval - want bool - }{ - {"never silences", AskForApproval{String: "never"}, true}, - {"on-request loud", AskForApproval{String: "on-request"}, false}, - {"untrusted loud", AskForApproval{String: "untrusted"}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := IsSilent(&tc.policy); got != tc.want { - t.Fatalf("IsSilent(%+v) = %v, want %v", tc.policy, got, tc.want) - } - }) - } -} - -func TestIsSilent_PartialGranularIsLoud(t *testing.T) { - // Flipping any single gate true must make IsSilent report false so - // the daemon registers the loud server-request handler. - for _, flip := range []func(*GranularAskForApproval){ - func(g *GranularAskForApproval) { g.SandboxApproval = true }, - func(g *GranularAskForApproval) { g.Rules = true }, - func(g *GranularAskForApproval) { g.SkillApproval = true }, - func(g *GranularAskForApproval) { g.RequestPermissions = true }, - func(g *GranularAskForApproval) { g.MCPElicitations = true }, - } { - g := GranularAskForApproval{} - flip(&g) - p := AskForApproval{Granular: &g} - if IsSilent(&p) { - t.Fatalf("IsSilent must be false when any gate is true: %+v", g) - } - } -} - -func TestAskForApproval_MarshalString(t *testing.T) { - p := AskForApproval{String: "never"} - raw, err := json.Marshal(p) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if string(raw) != `"never"` { - t.Fatalf("marshal string variant = %s", raw) - } -} - -func TestAskForApproval_MarshalGranular(t *testing.T) { - g := GranularAskForApproval{RequestPermissions: true} - p := AskForApproval{Granular: &g} - raw, err := json.Marshal(p) - if err != nil { - t.Fatalf("marshal: %v", err) - } - want := `{"granular":{"sandbox_approval":false,"rules":false,"skill_approval":false,"request_permissions":true,"mcp_elicitations":false}}` - if string(raw) != want { - t.Fatalf("marshal granular = %s\nwant = %s", raw, want) - } -} - -func TestAskForApproval_MarshalEmpty_DefaultsSilent(t *testing.T) { - // Neither field set — must not produce `null` (codex rejects it). - var p AskForApproval - raw, err := json.Marshal(p) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if string(raw) == "null" { - t.Fatalf("empty AskForApproval must not marshal to null: %s", raw) - } - if string(raw) != `{"granular":{"sandbox_approval":false,"rules":false,"skill_approval":false,"request_permissions":false,"mcp_elicitations":false}}` { - t.Fatalf("empty AskForApproval marshal = %s", raw) - } -} - -func TestAskForApproval_MarshalBothErrors(t *testing.T) { - g := GranularAskForApproval{} - p := AskForApproval{String: "never", Granular: &g} - if _, err := json.Marshal(p); err == nil { - t.Fatal("setting both String and Granular must error on marshal") - } -} - -func TestAskForApproval_RoundTripString(t *testing.T) { - var got AskForApproval - if err := json.Unmarshal([]byte(`"on-request"`), &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got.String != "on-request" || got.Granular != nil { - t.Fatalf("round-trip = %+v", got) - } -} - -func TestAskForApproval_RoundTripGranular(t *testing.T) { - src := `{"granular":{"sandbox_approval":true,"rules":false,"skill_approval":false,"request_permissions":false,"mcp_elicitations":false}}` - var got AskForApproval - if err := json.Unmarshal([]byte(src), &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if got.Granular == nil || !got.Granular.SandboxApproval || got.String != "" { - t.Fatalf("round-trip = %+v", got) - } -} diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go index 0ba51146f..1304b87e5 100644 --- a/apps/daemon/internal/agent/codex/contracts.go +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -11,17 +11,10 @@ var ( _ agent.DurableSteerer = (*Session)(nil) _ agent.Steerer = (*Session)(nil) _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.PermissionResponder = (*Session)(nil) - _ agent.UserChoiceResponder = (*Session)(nil) _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*Prepared)(nil) - _ agent.PreparedCancellation = (*Prepared)(nil) _ agent.WorkspaceReader = (*Executor)(nil) _ agent.WorkspaceDirectoryLister = (*Executor)(nil) _ agent.WorkspaceWriter = (*Executor)(nil) - _ agent.WorkspaceReader = (*Prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) - _ agent.WorkspaceWriter = (*Prepared)(nil) ) diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 2c962fe2c..14d3277a0 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -16,11 +16,9 @@ var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{ Capabilities: proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilitySupported, Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, Usage: proto.CapabilitySupported, Resume: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilitySupported, Steering: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, @@ -51,7 +49,7 @@ func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.Su return discoverWithCheck(ctx, options, info, CheckCLIAvailable) } func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind, check func(context.Context, string) (string, error)) *agent.Runtime { - runtime := &agent.Runtime{Info: info, Session: Factory, SessionCapabilityContext: true, ExecutorCapabilityContext: true} + runtime := &agent.Runtime{Info: info} ctx, cancel := context.WithTimeout(parent, 15*time.Second) defer cancel() version, err := check(ctx, "") @@ -63,18 +61,9 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i caps := &runtime.Info.Capabilities caps.NativeSessionRecovery = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) + caps.WorkspaceReadPreparation = caps.LocalEnvironment caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) runtime.Executor = NewExecutorFactory() - if caps.LocalEnvironment.IsSupported() { - runtime.WorkspaceReadPreparation = true - runtime.Preparation = func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - prepared, err := Prepare(ctx, req) - if prepared == nil { - return nil, err - } - return prepared, err - } - } fmt.Fprintf(options.Stdout, "Codex preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index 652c5cc1e..87f94299c 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -15,7 +15,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return version, nil }) - if !runtime.Info.Available || runtime.Executor == nil || (runtime.Preparation != nil) != SupportsLocalEnvironment(version) || !runtime.SessionCapabilityContext || !runtime.ExecutorCapabilityContext { + if !runtime.Info.Available || runtime.Executor == nil || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() != SupportsLocalEnvironment(version) { t.Fatalf("factories: %+v", runtime) } if runtime.Info.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { @@ -29,7 +29,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { // The declaration must retain the complete baseline capability descriptor. func TestDeclaredCapabilityBaseline(t *testing.T) { - expected := map[string]bool{"SubagentObservations": true, "Streaming": true, "Permissions": true, "Usage": true, "Resume": true, "Steering": true, "MessageItems": true, "ToolObservations": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "WebSearchControl": true, "MessageImages": true, "FunctionResultImages": true, "SubagentControl": true, "DurableInputReceipts": true, "DurableTurns": true, "FunctionTools": true, "MCPHTTPTools": true, "MCPHTTPBearerAuth": true, "WorkspaceAuthoring": true} + expected := map[string]bool{"SubagentObservations": true, "Streaming": true, "Usage": true, "Resume": true, "Steering": true, "MessageItems": true, "ToolObservations": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "WebSearchControl": true, "MessageImages": true, "FunctionResultImages": true, "SubagentControl": true, "DurableInputReceipts": true, "DurableTurns": true, "FunctionTools": true, "MCPHTTPTools": true, "MCPHTTPBearerAuth": true} expected["ExecutionControls"], expected["TextVerbosity"] = SupportsTextVerbosity, SupportsTextVerbosity value := reflect.ValueOf(Declaration.Info.Capabilities) for i := 0; i < value.NumField(); i++ { @@ -49,7 +49,7 @@ func TestDeclaredCapabilityBaseline(t *testing.T) { func TestUnavailableRuntimeHasNoExecutionFactories(t *testing.T) { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return "", errors.New("missing") }) - if runtime.Info.Available || runtime.Executor != nil || runtime.Preparation != nil || runtime.Session == nil { + if runtime.Info.Available || runtime.Executor != nil { t.Fatalf("unavailable runtime: %+v", runtime) } } diff --git a/apps/daemon/internal/agent/codex/environment.go b/apps/daemon/internal/agent/codex/environment.go index 51ba1f0f6..725e5ef58 100644 --- a/apps/daemon/internal/agent/codex/environment.go +++ b/apps/daemon/internal/agent/codex/environment.go @@ -7,8 +7,6 @@ import ( "fmt" "os" "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Check the native provider instead of assuming an older binary honors the flag. @@ -40,22 +38,16 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) } // Native still recognizes the retired transport variables. Reject them before -// setup so inherited or operator options cannot select a separate executor. +// setup so the inherited environment cannot select a separate executor. // The explicit none selector remains part of native execution isolation. -func validateNativeTransportEnvironment(req proto.PromptRequestPayload) error { - options, err := buildSessionEnv(req.AgentOptions) - if err != nil { - return err - } - for _, environment := range [][]string{os.Environ(), options} { - for _, entry := range environment { - key, value, _ := strings.Cut(entry, "=") - if value == "" { - continue - } - if (key == "CODEX_EXEC_SERVER_URL" && value != "none") || strings.HasPrefix(key, "CODEX_EXEC_SERVER_NOISE_") { - return errors.New("codex: retired executor transport configuration is not supported") - } +func validateNativeTransportEnvironment() error { + for _, entry := range os.Environ() { + key, value, _ := strings.Cut(entry, "=") + if value == "" { + continue + } + if (key == "CODEX_EXEC_SERVER_URL" && value != "none") || strings.HasPrefix(key, "CODEX_EXEC_SERVER_NOISE_") { + return errors.New("codex: retired executor transport configuration is not supported") } } return nil diff --git a/apps/daemon/internal/agent/codex/environment_retired_test.go b/apps/daemon/internal/agent/codex/environment_retired_test.go index 08dab1f6b..f3e070843 100644 --- a/apps/daemon/internal/agent/codex/environment_retired_test.go +++ b/apps/daemon/internal/agent/codex/environment_retired_test.go @@ -24,33 +24,27 @@ func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { } } -func TestRetiredNativeTransportOptionsRejectedBeforeState(t *testing.T) { +func TestRetiredNativeTransportEnvironmentRejectedBeforeState(t *testing.T) { for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { - for _, source := range []string{"process", "options"} { - for _, none := range []bool{false, true} { - t.Run(key+"/"+source+"/"+map[bool]string{false: "local", true: "none"}[none], func(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.DisableExecutionEnvironment = none - if !none { - req.LocalEnvironment = &proto.LocalEnvironment{ID: "local"} - } - if source == "process" { - t.Setenv(key, "retired-private-value") - } else { - req.AgentOptions["env"] = map[string]any{key: "retired-private-value"} - } - p, err := newPreparation(t.Context(), req, cfg) - if p != nil || err == nil || !strings.Contains(err.Error(), "retired executor transport") || strings.Contains(err.Error(), "retired-private-value") { - t.Fatal("transport override admitted or disclosed", err) - } - if len(preparationFrames(t, root)) != 0 { - t.Fatal("retired transport started native process") - } - if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { - t.Fatal("retired transport created state", err) - } - }) - } + for _, none := range []bool{false, true} { + t.Run(key+"/"+map[bool]string{false: "local", true: "none"}[none], func(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.DisableExecutionEnvironment = none + if !none { + req.LocalEnvironment = &proto.LocalEnvironment{ID: "local"} + } + t.Setenv(key, "retired-private-value") + p, err := newPreparation(t.Context(), req, cfg) + if p != nil || err == nil || !strings.Contains(err.Error(), "retired executor transport") || strings.Contains(err.Error(), "retired-private-value") { + t.Fatal("transport override admitted or disclosed", err) + } + if len(preparationFrames(t, root)) != 0 { + t.Fatal("retired transport started native process") + } + if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { + t.Fatal("retired transport created state", err) + } + }) } } } @@ -58,7 +52,6 @@ func TestRetiredNativeTransportOptionsRejectedBeforeState(t *testing.T) { func TestNativeNoneSelectorRemainsSupported(t *testing.T) { req, cfg, root := preparationFixture(t) t.Setenv("CODEX_EXEC_SERVER_URL", "none") - req.AgentOptions["env"] = map[string]any{"CODEX_EXEC_SERVER_URL": "none"} p, err := newPreparation(t.Context(), req, cfg) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/codex/execution_controls.go b/apps/daemon/internal/agent/codex/execution_controls.go deleted file mode 100644 index 1ffde7adf..000000000 --- a/apps/daemon/internal/agent/codex/execution_controls.go +++ /dev/null @@ -1,26 +0,0 @@ -package codex - -import ( - "maps" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func executionOptions(req proto.PromptRequestPayload) map[string]any { - if req.ExecutionControls == nil && req.MCPHTTPServers == nil { - return req.AgentOptions - } - options := maps.Clone(req.AgentOptions) - if options == nil { - options = make(map[string]any) - } - if req.ExecutionControls != nil { - // Reuse native validation/catalog handling, overriding lower-priority operator options. - options["web_search"] = req.ExecutionControls.WebSearch - options["model_verbosity"] = req.ExecutionControls.TextVerbosity - } - if req.MCPHTTPServers != nil { - delete(options, "mcp_servers") - } - return options -} diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index b1cd9eac9..e9d6f5451 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -7,36 +7,18 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestExecutionControlsOverrideWithoutMutatingNativeOptions(t *testing.T) { +func TestExecutionControlsSelectNativeSettings(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - original := map[string]any{"model": "test-model", "web_search": "live", "model_verbosity": "high"} - request := proto.PromptRequestPayload{AgentOptions: original} - if got := executionOptions(request); !reflect.DeepEqual(got, original) { - t.Fatal("ordinary options changed") - } for _, search := range []string{"disabled", "cached", "live"} { for _, verbosity := range []string{"low", "medium", "high"} { - request.ExecutionControls = &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity} - options := executionOptions(request) - if options["model"] != "test-model" || original["web_search"] != "live" || original["model_verbosity"] != "high" { - t.Fatal("operator options mutated") - } - plan, err := BuildSessionPlan("run", "state", options) + plan, err := BuildSessionPlan("state", map[string]any{"model": "test-model"}, &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity}) 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{{"tools.experimental_request_user_input.enabled", "false"}, {"web_search", `"` + search + `"`}, {"model_verbosity", `"` + verbosity + `"`}} + if !reflect.DeepEqual(plan.ExtraConfig, want) { + t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) } } } @@ -48,8 +30,7 @@ func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { {}, {WebSearch: "disabled"}, {TextVerbosity: "medium"}, {WebSearch: "invalid", TextVerbosity: "medium"}, {WebSearch: "disabled", TextVerbosity: "invalid"}, } { - options := executionOptions(proto.PromptRequestPayload{ExecutionControls: &controls}) - if plan, err := BuildSessionPlan("run", "state", options); err == nil { + if plan, err := BuildSessionPlan("state", nil, &controls); err == nil { plan.Cleanup() t.Fatal("invalid controls accepted", controls) } diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 2f1e2eb09..d8997e525 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -69,11 +69,10 @@ 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} + bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, runID: runID, out: out} if previous != nil { s.threadID = previous.currentThreadID() s.retiredTurns = make(map[string]bool, len(previous.retiredTurns)+1) @@ -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 227ad311c..f14f1340e 100644 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -3,6 +3,7 @@ package codex import ( "context" "encoding/json" + "log/slog" "os" "os/exec" "strings" @@ -11,7 +12,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // This opt-in test uses the real pinned harness and an explicitly configured @@ -47,10 +47,10 @@ func TestExecutorNativeReuse(t *testing.T) { } cfg := defaultSessionConfig() cfg.codexBinary = binary - cfg.logger = obslog.Discard() + cfg.logger = slog.New(slog.DiscardHandler) req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "executor-native", - StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, + DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, AgentOptions: map[string]any{"model": model, "model_provider": map[string]any{"base_url": endpoint, "protocol": "responses", "api_key": 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 01245f944..1f1696d6e 100644 --- a/apps/daemon/internal/agent/codex/executor_test.go +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -2,8 +2,10 @@ package codex import ( "context" + "encoding/json" "errors" "os" + "strings" "sync" "sync/atomic" "testing" @@ -17,22 +19,32 @@ import ( func executorFixture(t *testing.T, mode string) (*Executor, string) { t.Helper() req, cfg, root := preparationFixture(t) + e, err := testExecutor(t, mode, req, cfg) + if err != nil { + t.Fatal(err) + } + return e, root +} + +// testExecutor prepares through the production factory and closes the owner at cleanup. +func testExecutor(t *testing.T, mode string, req proto.PromptRequestPayload, cfg sessionConfig) (*Executor, error) { + t.Helper() t.Setenv("OAC_TEST_EXECUTOR_MODE", mode) ownerCtx, cancelOwner := context.WithCancel(context.Background()) t.Cleanup(cancelOwner) e, err := newExecutor(ownerCtx, req, cfg) - if err != nil { - t.Fatal(err) + if e != nil { + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Error(err) + } + }) } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := e.Close(ctx); err != nil { - t.Error(err) - } - }) - return e, root + return e, err } + func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) agent.TurnSettlement { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -52,6 +64,21 @@ func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) } return settlement } + +// settledFrames waits for a Turn to settle and returns its complete output. +func settledFrames(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) []proto.Envelope { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if _, err := turn.AwaitSettlement(ctx); errors.Is(err, context.DeadlineExceeded) { + t.Fatal("Turn did not settle") + } + var frames []proto.Envelope + for frame := range out { + frames = append(frames, frame) + } + return frames +} func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { e, root := executorFixture(t, "complete") var previous agent.Turn @@ -84,6 +111,95 @@ func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { t.Fatal("normal completion closed executor") } } +func TestExecutorFreezesPreparedConfiguration(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "resumed"}[resume], func(t *testing.T) { + req, cfg, root := preparationFixture(t) + expectedThread := "thread/start" + if resume { + req.AgentSessionID, expectedThread = "fixture-native-thread", "thread/resume" + } + e, err := testExecutor(t, "complete", req, cfg) + if err != nil { + t.Fatal(err) + } + assertPreparationOnly(t, root) + cwd := e.prepared.plan.Cwd + // Caller-owned data cannot revise the prepared native configuration. + req.AgentOptions["model"] = "different-model" + req.AgentSessionID = "different-thread" + copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) + if err != nil { + t.Fatal(err) + } + awaitExecutorTurn(t, turn, out) + counts := map[string]int{} + for _, frame := range preparationFrames(t, root) { + counts[frame.Method]++ + var params struct { + Model string `json:"model"` + ThreadID string `json:"threadId"` + DynamicTools []dynamicFunctionTool `json:"dynamicTools"` + Cwd string `json:"cwd"` + Environments json.RawMessage `json:"environments"` + } + if err := json.Unmarshal(frame.Params, ¶ms); err != nil { + t.Fatal(err) + } + if frame.Method == "thread/start" && (params.Model != "fixture-model" || len(params.DynamicTools) != 1 || + !strings.Contains(string(params.DynamicTools[0].InputSchema), "integer") || params.Cwd != cwd) { + t.Fatal("prepared configuration changed", string(frame.Params)) + } + if frame.Method == "thread/resume" && params.ThreadID != "fixture-native-thread" { + t.Fatal("prepared resume changed") + } + if len(params.Environments) != 0 { + t.Fatal("prepared environment changed") + } + } + if counts["initialize"] != 1 || counts["environment/status"] != 2 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { + t.Fatal("unexpected native setup/start count", counts) + } + }) + } +} + +func TestExecutorUnavailableOwnerStartsNoTurn(t *testing.T) { + for _, reason := range []string{"closed", "owner cancelled", "rpc exited"} { + t.Run(reason, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + owner, cancelOwner := context.WithCancel(context.Background()) + defer cancelOwner() + e, err := newExecutor(owner, req, cfg) + if err != nil { + t.Fatal(err) + } + defer e.Close(context.Background()) + switch reason { + case "closed": + err = e.Close(t.Context()) + case "owner cancelled": + cancelOwner() + case "rpc exited": + err = e.prepared.session.rpc.Close() + } + if err != nil { + t.Fatal(err) + } + out := make(chan proto.Envelope, 1) + if turn, err := e.StartTurn(t.Context(), "late-run", proto.TextInput("must not start"), out); turn != nil || err == nil { + t.Fatal("unavailable executor started a Turn", err) + } + if len(out) != 0 { + t.Fatal("rejected start emitted output") + } + assertPreparationOnly(t, root) + }) + } +} + func TestExecutorCancellationSettlesThenReuses(t *testing.T) { e, root := executorFixture(t, "complete") out := make(chan proto.Envelope, 20) @@ -165,7 +281,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/executor_turn.go b/apps/daemon/internal/agent/codex/executor_turn.go index d02eb988c..60746293f 100644 --- a/apps/daemon/internal/agent/codex/executor_turn.go +++ b/apps/daemon/internal/agent/codex/executor_turn.go @@ -44,7 +44,6 @@ func (s *Session) settleExecutorTurn(startErr error) { s.operationMu.Unlock() s.stopSteering() s.stopFunctionCalls() - s.stopCodexInteractionTimers() if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { s.cancelFn() s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) @@ -120,7 +119,6 @@ func (s *Session) cancelExecutorTurn(ctx context.Context) error { defer close(s.cancelReady) s.stopFunctionCalls() turnID, active := s.stopSteering() - s.stopCodexInteractionTimers() if !s.terminal.Load() && active { if turnID == "" { s.cancelErr = errors.New("codex: cancellation has no native turn identity") @@ -150,9 +148,7 @@ func (s *Session) cancelExecutorTurn(ctx context.Context) error { var _ agent.Turn = (*Session)(nil) -// Server callbacks retain their originating Turn. MCP elicitation may be a -// standalone thread-scoped request in the native protocol; a supplied Turn ID -// must still match exactly. +// Server callbacks retain their originating Turn, whose ID must match exactly. func (s *Session) onServerRequest(method string, handler ServerRequestHandler) { s.rpc.OnServerRequest(method, func(raw json.RawMessage, id any) (any, error) { if s.executor != nil { @@ -161,8 +157,7 @@ func (s *Session) onServerRequest(method string, handler ServerRequestHandler) { TurnID string `json:"turnId"` } if json.Unmarshal(raw, &scope) != nil || !s.isRootThread(scope.ThreadID) || s.terminal.Load() || - (scope.TurnID != "" && !s.isRootTurn(scope.ThreadID, scope.TurnID)) || - (scope.TurnID == "" && method != "mcpServer/elicitation/request") { + scope.TurnID == "" || !s.isRootTurn(scope.ThreadID, scope.TurnID) { return nil, errors.New("codex: request does not belong to the active turn") } } diff --git a/apps/daemon/internal/agent/codex/function_receipt_test.go b/apps/daemon/internal/agent/codex/function_receipt_test.go index decbf235e..fd138a009 100644 --- a/apps/daemon/internal/agent/codex/function_receipt_test.go +++ b/apps/daemon/internal/agent/codex/function_receipt_test.go @@ -16,7 +16,7 @@ import ( func TestFunctionResultWaitsForNativeCompletion(t *testing.T) { client, server, cleanup := NewTestClient() defer cleanup() - session, output := newInteractionTestSession(client.JSONRPCClient) + session, output := newFunctionTestSession(client.JSONRPCClient) session.setThreadID("thread") session.startSteering("thread", "turn") var err error @@ -61,7 +61,7 @@ func pendingReceiptContext(t *testing.T, ctx context.Context) (*Session, ServerS t.Helper() client, server, cleanup := NewTestClient() t.Cleanup(cleanup) - s, out := newInteractionTestSession(client.JSONRPCClient) + s, out := newFunctionTestSession(client.JSONRPCClient) s.cfg.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) s.setThreadID("thread") s.startSteering("thread", "turn") @@ -169,7 +169,7 @@ func TestFunctionReceiptShutdownDoesNotConfirmApplication(t *testing.T) { func TestFunctionReceiptDeadlineEndsUncertainExecution(t *testing.T) { client, server, cleanup := NewTestClient() defer cleanup() - s, out := newInteractionTestSession(client.JSONRPCClient) + s, out := newFunctionTestSession(client.JSONRPCClient) s.setThreadID("thread") s.startSteering("thread", "turn") s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) @@ -207,7 +207,7 @@ func TestFunctionReceiptDeadlineEndsUncertainExecution(t *testing.T) { func TestFunctionReceiptWinsSimultaneousDeadline(t *testing.T) { for range 100 { client, _, cleanup := NewTestClient() - s, _ := newInteractionTestSession(client.JSONRPCClient) + s, _ := newFunctionTestSession(client.JSONRPCClient) s.setThreadID("thread") s.startSteering("thread", "turn") s.functions, _ = prepareFunctionTools(nil) diff --git a/apps/daemon/internal/agent/codex/function_write_receipt_test.go b/apps/daemon/internal/agent/codex/function_write_receipt_test.go index 49cdc6826..7e6730647 100644 --- a/apps/daemon/internal/agent/codex/function_write_receipt_test.go +++ b/apps/daemon/internal/agent/codex/function_write_receipt_test.go @@ -31,7 +31,7 @@ func (w *confirmedResultWriter) Close() error { w.once.Do(func() { close(w.close func TestFunctionConfirmedReceiptDuringWriteDeadline(t *testing.T) { client, _, cleanup := NewTestClient() defer cleanup() - s, out := newInteractionTestSession(client.JSONRPCClient) + s, out := newFunctionTestSession(client.JSONRPCClient) s.setThreadID("thread") s.startSteering("thread", "turn") s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) @@ -64,7 +64,7 @@ func TestFunctionConfirmedReceiptDuringWriteDeadline(t *testing.T) { func TestFunctionUnconfirmedWriteDeadlineClosesNative(t *testing.T) { client, _, cleanup := NewTestClient() defer cleanup() - s, out := newInteractionTestSession(client.JSONRPCClient) + s, out := newFunctionTestSession(client.JSONRPCClient) s.setThreadID("thread") s.startSteering("thread", "turn") s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) diff --git a/apps/daemon/internal/agent/codex/functions_test.go b/apps/daemon/internal/agent/codex/functions_test.go index 1fc34979f..a2fd9c752 100644 --- a/apps/daemon/internal/agent/codex/functions_test.go +++ b/apps/daemon/internal/agent/codex/functions_test.go @@ -12,12 +12,20 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +func newFunctionTestSession(rpc *JSONRPCClient) (*Session, <-chan proto.Envelope) { + out := make(chan proto.Envelope, 1) + ctx, cancel := context.WithCancel(context.Background()) + s := &Session{runID: "run-test", out: out, rpc: rpc, cancelCtx: ctx, cancelFn: cancel} + s.registerHandlers() + return s, out +} + func TestFunctionCallWaitsAndRepliesOnce(t *testing.T) { for _, success := range []bool{true, false} { t.Run(map[bool]string{true: "success", false: "failure"}[success], func(t *testing.T) { tc, srv, cleanup := NewTestClient() defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) + s, out := newFunctionTestSession(tc.JSONRPCClient) var err error s.functions, err = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}) if err != nil { @@ -88,7 +96,7 @@ func TestFunctionCallWaitsAndRepliesOnce(t *testing.T) { func TestFunctionCallRejectsUnregisteredAndClosedRuns(t *testing.T) { tc, _, cleanup := NewTestClient() defer cleanup() - s, _ := newInteractionTestSession(tc.JSONRPCClient) + s, _ := newFunctionTestSession(tc.JSONRPCClient) s.setThreadID("thread") s.startSteering("thread", "turn") s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) diff --git a/apps/daemon/internal/agent/codex/generation_config.go b/apps/daemon/internal/agent/codex/generation_config.go deleted file mode 100644 index 8cce97fbf..000000000 --- a/apps/daemon/internal/agent/codex/generation_config.go +++ /dev/null @@ -1,17 +0,0 @@ -package codex - -func extraConfigFromOpts(opts map[string]any) [][2]string { - var out [][2]string - if rs := stringOpt(opts, "reasoning_summary"); rs != "" { - // codex app-server has no per-call flag; route via -c override. - // TOML literal — quoted string keeps shell-safe special chars. - out = append(out, [2]string{"model_reasoning_summary", strconv(rs)}) - } - if mode := stringOpt(opts, "web_search"); mode != "" { - out = append(out, [2]string{"web_search", strconv(mode)}) - } - if verbosity := stringOpt(opts, "model_verbosity"); verbosity != "" { - out = append(out, [2]string{"model_verbosity", strconv(verbosity)}) - } - return out -} diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go index 1efa79cc5..cd3577d3c 100644 --- a/apps/daemon/internal/agent/codex/harness_config_test.go +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -13,10 +13,10 @@ import ( func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, err := BuildSessionPlan("run", "native-config", map[string]any{ + plan, err := BuildSessionPlan("native-config", map[string]any{ "model": "fixture", "harness_config": map[string]any{"model_reasoning_effort": "high"}, "model_provider": map[string]any{"base_url": "https://provider.invalid/v1", "protocol": "responses", "api_key": "test-key"}, - }) + }, nil) if err != nil { t.Fatal(err) } @@ -27,7 +27,7 @@ func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { } func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { - _, err := BuildSessionPlan("run", "", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}) + _, err := BuildSessionPlan("", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}, nil) if err != harnessconfig.ErrHarnessConfig { t.Fatalf("configuration must fail before filesystem preparation: %v", err) } diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index aac6309cb..653726a43 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -8,17 +8,14 @@ import ( "strings" ) -// mcpServerConfig is the daemon-internal MCP server config flattened -// from agent_options["mcp_servers"] (rendered by render.TargetCodex / -// the mcpServers JSON shape). Written into /config.toml +// mcpServerConfig is the daemon-internal MCP server config resolved from +// the request's MCP bindings. Written into /config.toml // before spawning the app-server child. type mcpServerConfig struct { Name string URL string - Headers map[string]string Command string Args []string - Env map[string]string EnabledTools *[]string Required bool BearerTokenEnvVar string @@ -76,7 +73,6 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e } b.WriteString("]\n") } - writeMCPHeaderMap(&b, "http_headers", srv.Headers) writeMCPHeaderMap(&b, "env_http_headers", srv.EnvHTTPHeaders) b.WriteByte('\n') continue @@ -94,22 +90,6 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e } b.WriteString("]\n") } - if len(srv.Env) > 0 { - envKeys := make([]string, 0, len(srv.Env)) - for k := range srv.Env { - envKeys = append(envKeys, k) - } - sort.Strings(envKeys) - b.WriteString("\n[mcp_servers.") - b.WriteString(tomlQuoteString(name)) - b.WriteString(".env]\n") - for _, k := range envKeys { - b.WriteString(tomlQuoteString(k)) - b.WriteString(" = ") - b.WriteString(tomlQuoteString(srv.Env[k])) - b.WriteByte('\n') - } - } b.WriteByte('\n') } diff --git a/apps/daemon/internal/agent/codex/mcp_config_test.go b/apps/daemon/internal/agent/codex/mcp_config_test.go index 499733029..c55ca481a 100644 --- a/apps/daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/daemon/internal/agent/codex/mcp_config_test.go @@ -33,14 +33,13 @@ func TestWriteCodexMCPConfig_DeterministicOrdering(t *testing.T) { } } -func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { +func TestWriteCodexMCPConfig_EmitsCommandArgs(t *testing.T) { dir := t.TempDir() servers := map[string]mcpServerConfig{ "docs": { Name: "docs", Command: "docs-server", Args: []string{"--port", "8080"}, - Env: map[string]string{"DOCS_TOKEN": "secret"}, }, } if err := writeCodexMCPConfig(dir, servers); err != nil { @@ -53,21 +52,14 @@ func TestWriteCodexMCPConfig_EmitsCommandArgsEnv(t *testing.T) { if !strings.Contains(string(body), `args = ["--port", "8080"]`) { t.Fatalf("missing args: %s", body) } - if !strings.Contains(string(body), `[mcp_servers."docs".env]`) { - t.Fatalf("missing env table: %s", body) - } - if !strings.Contains(string(body), `"DOCS_TOKEN" = "secret"`) { - t.Fatalf("missing env entry: %s", body) - } } func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { dir := t.TempDir() servers := map[string]mcpServerConfig{ "docs": { - Name: "docs", - URL: "https://docs.example.com/mcp", - Headers: map[string]string{"Authorization": "Bearer token"}, + Name: "docs", + URL: "https://docs.example.com/mcp", }, } if err := writeCodexMCPConfig(dir, servers); err != nil { @@ -77,9 +69,6 @@ func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { t.Fatalf("remote config: %s", body) } - if !strings.Contains(string(body), `http_headers = {"Authorization" = "Bearer token"}`) { - t.Fatalf("remote headers: %s", body) - } } // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index bdeb2af17..d5a935f7d 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -63,7 +63,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_bearer_test.go b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go index 4c1ed5d8c..0223e6716 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -81,7 +81,7 @@ func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { token := "synthetic-catalog-secret" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{AgentStateKey: "catalog", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, - AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}} + AgentOptions: map[string]any{"model": "fixture-model"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} cfg := defaultSessionConfig() cfg.codexBinary = binary plan, _, err := prepareSessionPlan(t.Context(), req, cfg) diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight.go b/apps/daemon/internal/agent/codex/mcp_http_preflight.go index 7aeae62aa..b499038fb 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight.go @@ -46,8 +46,7 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) return false } if expected.URL != "" { - if server["url"] != expected.URL || !matchesMCPHeaderMap(server, "http_headers", expected.Headers) || - !matchesMCPHeaderMap(server, "env_http_headers", expected.EnvHTTPHeaders) { + if server["url"] != expected.URL || !matchesMCPHeaderMap(server, "env_http_headers", expected.EnvHTTPHeaders) { return false } delete(server, "url") diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index 40c09abdd..c57e3297c 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -1,13 +1,11 @@ package codex import ( - "context" "encoding/json" "os" "path/filepath" "strings" "testing" - "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -155,9 +153,9 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { path := filepath.Join(root, "native-config.json") writeMCPHTTPConfigResponse(t, path, response) t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", path) - p, err := newPreparation(t.Context(), req, cfg) + e, err := testExecutor(t, "complete", req, cfg) if strings.HasPrefix(mode, "reject") { - if err == nil || p != nil { + if err == nil || e != nil { t.Fatal("ambient MCP configuration admitted") } assertPreparationOnly(t, root) @@ -167,18 +165,18 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { if err != nil { t.Fatal(err) } - defer p.Close() assertPreparationOnly(t, root) home, err := allocCodexHome(req.AgentStateKey) if err != nil { t.Fatal(err) } - s, err := p.start(t.Context(), "actual-run", proto.TextInput("actual prompt"), make(chan proto.Envelope, 8)) + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) if err != nil { t.Fatal(err) } - defer s.Cancel(context.Background()) - frames := waitPreparationMethod(t, root, "turn/start") + awaitExecutorTurn(t, turn, out) + frames := preparationFrames(t, root) checked, statuses := false, 0 for _, frame := range frames { if frame.Method == "environment/status" { @@ -203,12 +201,6 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Fatal("preflight started discovery") } } - _ = s.Cancel(context.Background()) - select { - case <-s.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("native fixture did not release") - } }) } } diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 7720bee3f..20ac1767f 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -20,21 +20,13 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { {ConnectionOrigin: "service", ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, {ConnectionOrigin: "service", ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, } - original := map[string]any{ - "mcp_servers": map[string]any{"operator": map[string]any{"command": "operator-mcp"}}, - "enable_features": []any{"apps", "plugins", "unrelated"}, - } - req := proto.PromptRequestPayload{AgentStateKey: "public-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: original} - req.AgentOptions = executionOptions(req) + req := proto.PromptRequestPayload{AgentStateKey: "public-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) if err != nil { t.Fatal(err) } defer plan.Cleanup() - if original["mcp_servers"] == nil || req.AgentOptions["mcp_servers"] != nil { - t.Fatal("declaration failed to replace operator MCP without mutation") - } - if !slices.Equal(plan.EnableFeatures, []string{"unrelated"}) || !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) @@ -53,9 +45,6 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatalf("missing native config %q", expected) } } - if strings.Contains(string(config), "operator") { - t.Fatal("operator MCP was rendered") - } tools[0] = "mutated" if (*plan.mcpServers["docs.server"].EnabledTools)[0] != "lookup.docs" { t.Fatal("prepared allowlist retained caller-owned memory") diff --git a/apps/daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go index bd4bbec2b..4dc500470 100644 --- a/apps/daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -1,13 +1,13 @@ package codex import ( - "context" "os" "path/filepath" "strings" "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -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.AgentOptions = map[string]any{"model": "fixture-model"} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} req.MCPHTTPServers = &servers @@ -35,17 +34,20 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", config) gate := filepath.Join(root, "required-initialization") t.Setenv("OAC_TEST_PREPARATION_THREAD_GATE", gate) - p, err := newPreparation(t.Context(), req, cfg) + e, err := testExecutor(t, "complete", req, cfg) if err != nil { t.Fatal(err) } - defer p.Close() out := make(chan proto.Envelope, 16) - s, err := p.start(t.Context(), "required-run", proto.TextInput("actual prompt"), out) - if err != nil { - t.Fatal(err) + type started struct { + turn agent.Turn + err error } - defer s.Cancel(context.Background()) + result := make(chan started, 1) + go func() { + turn, err := e.StartTurn(t.Context(), "required-run", proto.TextInput("actual prompt"), out) + result <- started{turn, err} + }() waitPreparationMethod(t, root, method) time.Sleep(100 * time.Millisecond) assertNoTurn := func() { @@ -68,19 +70,23 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. if err := os.WriteFile(gate, []byte(state), 0o600); err != nil { t.Fatal(err) } - if state == "ready" { - waitPreparationMethod(t, root, "turn/start") - return - } + var start started select { - case <-s.waitDone: + case start = <-result: case <-time.After(4 * time.Second): - t.Fatal("failed initialization did not terminate") + t.Fatal("native initialization did not finish") + } + if (start.err == nil) != (state == "ready") { + t.Fatal("native initialization outcome changed", start.err) + } + frames := settledFrames(t, start.turn, out) + if state == "ready" { + return } assertNoTurn() failed := false - for len(out) > 0 { - failed = (<-out).Type == proto.TypeError || failed + for _, frame := range frames { + failed = frame.Type == proto.TypeError || failed } if !failed { t.Fatal("native initialization failure was not reported") diff --git a/apps/daemon/internal/agent/codex/model_route_test.go b/apps/daemon/internal/agent/codex/model_route_test.go index 2e4a716cc..32aeb4680 100644 --- a/apps/daemon/internal/agent/codex/model_route_test.go +++ b/apps/daemon/internal/agent/codex/model_route_test.go @@ -11,7 +11,7 @@ func TestPlanRejectsNonNativeFrozenProvider(t *testing.T) { for _, protocol := range []string{"anthropic", "chat_completions"} { t.Run(protocol, func(t *testing.T) { provider := map[string]any{"protocol": protocol, "base_url": "https://model.invalid/v1", "api_key": "private-sentinel"} - plan, err := BuildSessionPlan("recovered", "frozen-state", map[string]any{"model": "frozen-model", "model_provider": provider}) + plan, err := BuildSessionPlan("frozen-state", map[string]any{"model": "frozen-model", "model_provider": provider}, nil) if plan.Cleanup != nil { plan.Cleanup() } @@ -30,7 +30,7 @@ func TestPlanRejectsIncompleteExplicitProvider(t *testing.T) { {"model": "chosen", "model_provider": nil}, {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": "fixture"}}, } { - if _, err := BuildSessionPlan("frozen", "state", options); err == nil { + if _, err := BuildSessionPlan("state", options, nil); err == nil { t.Fatal("explicit provider fell back to native defaults") } } diff --git a/apps/daemon/internal/agent/codex/model_verbosity_test.go b/apps/daemon/internal/agent/codex/model_verbosity_test.go index 957a70132..e1b9ecd45 100644 --- a/apps/daemon/internal/agent/codex/model_verbosity_test.go +++ b/apps/daemon/internal/agent/codex/model_verbosity_test.go @@ -37,7 +37,7 @@ func TestPrepareModelVerbosity(t *testing.T) { if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { t.Fatal(err) } - plan, err := BuildSessionPlan("run", "state", map[string]any{"model": "known-model", "model_verbosity": "high"}) + plan, err := BuildSessionPlan("state", map[string]any{"model": "known-model"}, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "high"}) if err != nil { t.Fatal(err) } @@ -80,7 +80,7 @@ func TestPrepareDefaultModelVerbosity(t *testing.T) { for _, model := range []string{"supported", "unsupported", "unknown-provider-model"} { for _, level := range []string{"low", "medium", "high"} { t.Run(model+"/"+level, func(t *testing.T) { - plan, err := BuildSessionPlan("run", "state", executionOptions(proto.PromptRequestPayload{AgentOptions: map[string]any{"model": model}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: level}})) + plan, err := BuildSessionPlan("state", map[string]any{"model": model}, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: level}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 54defb0c9..1f53a8d24 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -6,10 +6,11 @@ import ( "fmt" "os" "path/filepath" - "sort" + "slices" "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" ) @@ -22,17 +23,16 @@ type SessionPlan struct { Cwd string // Env is the full environment slice (KEY=value) to layer onto - // os.Environ() before spawning. Includes CODEX_HOME, plus any - // caller-provided OPENAI_API_KEY / CODEX_API_KEY / proxy vars. + // os.Environ() before spawning. Includes CODEX_HOME and the entries + // of the env option. Env []string // ExtraConfig is a list of `-c key=value` overrides applied at the - // app-server CLI. Used to layer model_reasoning_summary etc. without - // editing config.toml. + // app-server CLI. ExtraConfig [][2]string // EnableFeatures / DisableFeatures forward to `--enable / --disable` - // flags. Today empty by default; reserved for future ARC opt-in. + // flags for the profiles the adapter configures. EnableFeatures []string DisableFeatures []string @@ -54,53 +54,28 @@ 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 - // ApprovalPolicy + Sandbox apply to both new and resumed threads. - ApprovalPolicy AskForApproval - Sandbox SandboxMode - Permissions string - // Cleanup is the deferred housekeeping the session must run after the child exits. Cleanup func() } -// BuildSessionPlan derives a SessionPlan from PromptRequestPayload's -// fields. Options use a string-keyed map. -// -// The agent_options keys this function reads: -// -// model string codex model slug, e.g. "gpt-5.5" -// system_prompt string forwarded as developerInstructions -// override_system_prompt string replaces system_prompt entirely when -// non-empty -// env map[string]any extra env vars (KEY=string-value) -// mcp_servers map[string]any rendered MCP server config — written -// to /config.toml [mcp_servers] -// model_provider object frozen upstream protocol, endpoint, credential and token limits -// reasoning_summary string one of auto/concise/detailed/none — -// routed via -c model_reasoning_summary -// mode string Codex collaboration mode: default/plan -// enable_features []any string list, forwarded as --enable -// disable_features []any string list, forwarded as --disable +// BuildSessionPlan derives a SessionPlan from the request's agent_options and +// execution controls. It reads these agent_options keys: // -// The codex binary itself is resolved via PATH only — there's no -// per-prompt override knob. If a deployment needs a custom binary -// location, set it via the daemon's process environment (PATH / -// codexBinary in sessionConfig) rather than per-call. +// model string codex model slug, e.g. "gpt-5.5" +// system_prompt string forwarded as developerInstructions +// model_provider object frozen upstream protocol, endpoint, credential and token limits +// harness_config object native parameters accepted by harnessconfig/codex // -// Daemon-managed Codex sessions bypass approvals and the engine sandbox. -func BuildSessionPlan(runID, agentStateKey string, opts map[string]any) (SessionPlan, error) { - cleanup := func() {} +// Execution controls select the native web_search and model_verbosity settings. +// The codex binary itself is resolved via PATH only. +func BuildSessionPlan(agentStateKey string, opts map[string]any, controls *proto.ExecutionControls) (SessionPlan, error) { plan := SessionPlan{ - CollaborationMode: CollaborationModeDefault, - ApprovalPolicy: AskForApproval{String: "never"}, - Sandbox: SandboxDangerFullAcces, - Cleanup: cleanup, + // Harnesses run unattended: Codex never offers its ask-the-user tool. + ExtraConfig: [][2]string{{"tools.experimental_request_user_input.enabled", "false"}}, + Cleanup: func() {}, } nativeConfig, err := harnessconfiguration.Configuration().PrepareHarnessConfig(opts) @@ -112,40 +87,17 @@ func BuildSessionPlan(runID, agentStateKey string, opts map[string]any) (Session return plan, errors.New("codex: model and complete model_provider are required") } - if value, present := opts["web_search"]; present { - switch value { - case "disabled", "cached", "live": - default: - return plan, fmt.Errorf("codex: web_search must be disabled, cached or live") + if controls != nil { + if !slices.Contains([]string{"disabled", "cached", "live"}, controls.WebSearch) { + return plan, errors.New("codex: web_search must be disabled, cached or live") } - } - - if value, present := opts["model_verbosity"]; present { - switch value { - case "low", "medium", "high": - default: - return plan, fmt.Errorf("codex: model_verbosity must be low, medium or high") + if !slices.Contains([]string{"low", "medium", "high"}, controls.TextVerbosity) { + return plan, errors.New("codex: text_verbosity must be low, medium or high") } } plan.Model = stringOpt(opts, "model") plan.SystemPrompt = stringOpt(opts, "system_prompt") - if override := stringOpt(opts, "override_system_prompt"); override != "" { - plan.SystemPrompt = override - } - if mode := CollaborationModeKind(stringOpt(opts, "mode")); mode != "" { - switch mode { - case CollaborationModeDefault, CollaborationModePlan: - plan.CollaborationMode = mode - default: - return plan, fmt.Errorf("codex: unsupported collaboration mode %q", mode) - } - } - - env, err := buildSessionEnv(opts) - if err != nil { - return plan, err - } codexHome, err := allocCodexHome(agentStateKey) if err != nil { @@ -154,38 +106,23 @@ func BuildSessionPlan(runID, agentStateKey string, opts map[string]any) (Session if err := resetGeneratedConfig(codexHome); err != nil { return plan, err } - env = append(env, "CODEX_HOME="+codexHome) - - // MCP servers come pre-rendered from server/internal/connector/agentdaemon - // (capabilityAdditions.MCPServers, rendered via render.TargetCodex) - // as a map of name → {command,args,env}. Write them into - // /config.toml so codex picks them up on startup. - mcpServers, err := normaliseMCPServers(opts["mcp_servers"]) - if err != nil { - return plan, err - } - if len(mcpServers) > 0 { - if err := writeCodexMCPConfig(codexHome, mcpServers); err != nil { - return plan, err - } - } + env := []string{"DISABLE_TELEMETRY=1", "CODEX_HOME=" + codexHome} provider, hasProvider, err := normaliseProviderConfig(opts["model_provider"]) if err != nil { - cleanup() return plan, err } if hasProvider { if err := writeCodexProviderConfig(codexHome, provider); err != nil { - cleanup() return plan, err } plan.ModelProvider = oacProviderSlug } plan.Env = env - plan.Cleanup = cleanup - plan.ExtraConfig = extraConfigFromOpts(opts) + if controls != nil { + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"web_search", strconv(controls.WebSearch)}, [2]string{"model_verbosity", strconv(controls.TextVerbosity)}) + } if effort, ok := nativeConfig["model_reasoning_effort"].(string); ok { plan.ModelReasoningEffort = effort plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_reasoning_effort", strconv(effort)}) @@ -198,8 +135,6 @@ func BuildSessionPlan(runID, agentStateKey string, opts map[string]any) (Session plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_provider", strconv(plan.ModelProvider)}) } - plan.EnableFeatures = stringListOpt(opts, "enable_features") - plan.DisableFeatures = stringListOpt(opts, "disable_features") return plan, nil } @@ -253,111 +188,6 @@ func resetGeneratedConfig(codexHome string) error { return nil } -func buildSessionEnv(opts map[string]any) ([]string, error) { - env := []string{ - "DISABLE_TELEMETRY=1", - } - raw, ok := opts["env"] - if !ok || raw == nil { - return env, nil - } - envMap, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("codex.BuildSessionPlan: env must be object, got %T", raw) - } - keys := make([]string, 0, len(envMap)) - for k := range envMap { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - s, ok := envMap[k].(string) - if !ok { - return nil, fmt.Errorf("codex.BuildSessionPlan: env[%q] must be string, got %T", k, envMap[k]) - } - env = append(env, k+"="+s) - } - return env, nil -} - -func stringListOpt(opts map[string]any, key string) []string { - if opts == nil { - return nil - } - raw, ok := opts[key] - if !ok || raw == nil { - return nil - } - arr, ok := raw.([]any) - if !ok { - return nil - } - out := make([]string, 0, len(arr)) - for _, v := range arr { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - out = append(out, s) - } - } - return out -} - -func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { - if raw == nil { - return nil, nil - } - m, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("codex: mcp_servers must be object, got %T", raw) - } - out := make(map[string]mcpServerConfig, len(m)) - for name, v := range m { - entry, ok := v.(map[string]any) - if !ok { - return nil, fmt.Errorf("codex: mcp_servers[%q] must be object, got %T", name, v) - } - srv := mcpServerConfig{Name: name} - if url, ok := entry["url"].(string); ok { - srv.URL = strings.TrimSpace(url) - } - if cmd, ok := entry["command"].(string); ok { - srv.Command = cmd - } - if args, ok := entry["args"].([]any); ok { - for _, a := range args { - if s, ok := a.(string); ok { - srv.Args = append(srv.Args, s) - } - } - } - if env, ok := entry["env"].(map[string]any); ok { - srv.Env = make(map[string]string, len(env)) - for k, val := range env { - if s, ok := val.(string); ok { - srv.Env[k] = s - } - } - } - if headers, ok := entry["headers"].(map[string]any); ok { - srv.Headers = make(map[string]string, len(headers)) - for key, value := range headers { - if text, ok := value.(string); ok { - srv.Headers[key] = text - } - } - } else if headers, ok := entry["headers"].(map[string]string); ok { - srv.Headers = headers - } - if srv.Command == "" && srv.URL == "" { - return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) - } - if srv.Command != "" && srv.URL != "" { - return nil, fmt.Errorf("codex: mcp_servers[%q] cannot set both command and url", name) - } - out[name] = srv - } - return out, nil -} - // normaliseProviderConfig validates and renders the frozen native provider. func normaliseProviderConfig(raw any) (providerConfig, bool, error) { if raw == nil { @@ -370,25 +200,6 @@ func normaliseProviderConfig(raw any) (providerConfig, bool, error) { return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses"}, true, nil } -// intOpt extracts an integer-shaped value from a map. JSON-decoded -// numbers arrive as float64; tests sometimes pass int directly. Both -// are accepted; non-numeric / missing yields 0. -func intOpt(m map[string]any, key string) int { - v, ok := m[key] - if !ok || v == nil { - return 0 - } - switch x := v.(type) { - case int: - return x - case int64: - return int(x) - case float64: - return int(x) - } - return 0 -} - func stringOpt(opts map[string]any, key string) string { if opts == nil { return "" diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index b8b8587ac..cb8910aaa 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -2,21 +2,18 @@ package codex import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "os" + "slices" "strings" "testing" ) func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", nil) + plan, err := BuildSessionPlan("conv-1/agent-1/codex", nil, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } - if plan.ApprovalPolicy.String != "never" { - t.Fatalf("default policy must bypass approvals, got %+v", plan.ApprovalPolicy) - } - if plan.Sandbox != SandboxDangerFullAcces { - t.Fatalf("default sandbox = %s, want danger-full-access", plan.Sandbox) + if !slices.Contains(plan.ExtraConfig, [2]string{"tools.experimental_request_user_input.enabled", "false"}) { + t.Fatalf("default plan must disable the native ask-the-user tool, got %+v", plan.ExtraConfig) } if plan.Cleanup == nil { t.Fatal("Cleanup must be non-nil") @@ -25,24 +22,17 @@ func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { } func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "env": map[string]any{ - "OPENAI_API_KEY": "sk-test", - }, - }) + plan, err := BuildSessionPlan("conv-1/agent-1/codex", nil, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } defer plan.Cleanup() hasCodexHome := false - hasOpenAI := false hasTelemetry := false for _, kv := range plan.Env { switch { case strings.HasPrefix(kv, "CODEX_HOME="): hasCodexHome = true - case kv == "OPENAI_API_KEY=sk-test": - hasOpenAI = true case kv == "DISABLE_TELEMETRY=1": hasTelemetry = true } @@ -50,9 +40,6 @@ func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { if !hasCodexHome { t.Fatalf("env missing CODEX_HOME: %+v", plan.Env) } - if !hasOpenAI { - t.Fatalf("env missing OPENAI_API_KEY: %+v", plan.Env) - } if !hasTelemetry { t.Fatalf("env missing DISABLE_TELEMETRY: %+v", plan.Env) } @@ -60,11 +47,11 @@ func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { stateKey := "conv-stable/agent-stable/codex" - planA, err := BuildSessionPlan("run-a", stateKey, nil) + planA, err := BuildSessionPlan(stateKey, nil, nil) if err != nil { t.Fatalf("BuildSessionPlan A: %v", err) } - planB, err := BuildSessionPlan("run-b", stateKey, nil) + planB, err := BuildSessionPlan(stateKey, nil, nil) if err != nil { t.Fatalf("BuildSessionPlan B: %v", err) } @@ -73,26 +60,6 @@ func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { } } -func TestBuildSessionPlan_RoutesReasoningSummary(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "reasoning_summary": "detailed", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if len(plan.ExtraConfig) != 1 { - t.Fatalf("ExtraConfig = %+v", plan.ExtraConfig) - } - kv := plan.ExtraConfig[0] - if kv[0] != "model_reasoning_summary" { - t.Fatalf("override key = %q", kv[0]) - } - if kv[1] != `"detailed"` { - t.Fatalf("override value = %q (must be TOML-quoted)", kv[1]) - } -} - func codexHomeFromEnv(env []string) string { for _, kv := range env { if strings.HasPrefix(kv, "CODEX_HOME=") { @@ -102,128 +69,6 @@ func codexHomeFromEnv(env []string) string { return "" } -func TestBuildSessionPlan_OverrideSystemPromptReplacesAppend(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "system_prompt": "user base", - "override_system_prompt": "you are pirate", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.SystemPrompt != "you are pirate" { - t.Fatalf("SystemPrompt = %q, want %q", plan.SystemPrompt, "you are pirate") - } -} - -func TestBuildSessionPlan_EmptyOverrideKeepsSystemPrompt(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "system_prompt": "user base", - "override_system_prompt": "", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.SystemPrompt != "user base" { - t.Fatalf("SystemPrompt = %q, want %q (empty override should not clobber)", plan.SystemPrompt, "user base") - } -} - -func TestBuildSessionPlan_ParsesCollaborationMode(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "mode": "plan", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.CollaborationMode != CollaborationModePlan { - t.Fatalf("CollaborationMode = %q, want plan", plan.CollaborationMode) - } -} - -func TestBuildSessionPlan_OmittedModeRetainsCurrentInstructions(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, mode := range []string{"", "default"} { - opts := map[string]any{"system_prompt": "current reference", "model": "MiniMax-M3"} - if mode != "" { - opts["mode"] = mode - } - plan, err := BuildSessionPlan("run", "conv/agent/codex", opts) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if plan.CollaborationMode != CollaborationModeDefault || plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { - t.Fatalf("mode %q did not retain the default turn instructions: %+v", mode, plan) - } - } -} - -func TestBuildSessionPlan_RejectsUnknownCollaborationMode(t *testing.T) { - _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "mode": "autopilot", - }) - if err == nil || !strings.Contains(err.Error(), "unsupported collaboration mode") { - t.Fatalf("expected unsupported collaboration mode error, got %v", err) - } -} - -func TestBuildSessionPlan_WritesMCPConfig(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "mcp_servers": map[string]any{ - "docs": map[string]any{ - "command": "docs-server", - "args": []any{"--port", "8080"}, - "env": map[string]any{"TOKEN": "abc"}, - }, - }, - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - // Find CODEX_HOME so we can verify the config.toml was written there. - codexHome := "" - for _, kv := range plan.Env { - if strings.HasPrefix(kv, "CODEX_HOME=") { - codexHome = strings.TrimPrefix(kv, "CODEX_HOME=") - break - } - } - if codexHome == "" { - t.Fatal("CODEX_HOME not in plan.Env") - } - // mcp_config.go writes /config.toml — verify it exists - // and contains the rendered server. - bodyBytes, err := os.ReadFile(codexHome + "/config.toml") - if err != nil { - t.Fatalf("read config.toml: %v", err) - } - body := string(bodyBytes) - if !strings.Contains(body, `[mcp_servers."docs"]`) { - t.Fatalf("config.toml missing docs server: %s", body) - } - if !strings.Contains(body, `command = "docs-server"`) { - t.Fatalf("config.toml missing command: %s", body) - } -} - -func TestBuildSessionPlan_MissingMCPCommandErrors(t *testing.T) { - _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", map[string]any{ - "mcp_servers": map[string]any{ - "broken": map[string]any{ - "args": []any{"--x"}, - // no command - }, - }, - }) - if err == nil { - t.Fatal("missing mcp command must surface as error") - } -} - func TestNativeInputPreservesText(t *testing.T) { inputs, err := nativeInput(proto.TextInput(" hello world ")) if err != nil { @@ -250,12 +95,3 @@ func TestFirstUserInput_WhitespaceIsUnchanged(t *testing.T) { t.Fatalf("input = %+v, %v", inputs, err) } } - -func TestStringListOpt_FiltersEmpties(t *testing.T) { - got := stringListOpt(map[string]any{ - "enable_features": []any{"a", "", " ", "b"}, - }, "enable_features") - if len(got) != 2 || got[0] != "a" || got[1] != "b" { - t.Fatalf("filter = %+v", got) - } -} 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/permission_profile_test.go b/apps/daemon/internal/agent/codex/permission_profile_test.go index dc250c7be..e69720d6e 100644 --- a/apps/daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/daemon/internal/agent/codex/permission_profile_test.go @@ -16,9 +16,6 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { if err != nil { t.Fatal(err) } - if plan.Sandbox != "danger-full-access" || plan.Permissions != "" || plan.ApprovalPolicy.String != "never" { - t.Fatal("Runtime must bypass inner sandbox", plan) - } if plan.Cwd != req.LocalEnvironment.WorkspaceRoot { t.Fatal("native cwd is not the bound workspace root", plan.Cwd) } diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 1b4589222..468e08576 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -12,30 +12,6 @@ import ( obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) -// Prepare connects the native harness without creating a thread or starting model -// work. req supplies configuration and a stable state key, but no RunID or Prompt. -// owner owns the entire harness lifetime, including the eventual Session; it must -// not be a disposable readiness-request context. Initialization/readiness use their -// existing operation-local deadlines. Close an unused preparation explicitly. -func Prepare(owner context.Context, req proto.PromptRequestPayload) (*Prepared, error) { - return newPreparation(owner, req, defaultSessionConfig()) -} - -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - 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 { - return nil, err - } - defer prepared.Close() - return prepared.start(parent, runID, prompt, out) -} - func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (_ *Prepared, resultErr error) { if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { return nil, errors.New("codex: structured output is not qualified") @@ -43,9 +19,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") } @@ -62,9 +35,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if err != nil { return nil, err } - req.AgentStateKey = effectiveAgentStateKey(req) - - req.AgentOptions = executionOptions(req) planStarted := time.Now() plan, skillRoots, err := prepareSessionPlan(parent, req, cfg) observePreparationStage(parent, "session_plan", planStarted, err) @@ -90,11 +60,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg rpc := NewJSONRPCClient(rpcCfg) s := &Session{ - nativeHome: nativeHomeFromPlan(plan), - functions: functions, - observeMessages: req.ObserveMessages, - - observeToolObservations: req.ObserveToolObservations, + nativeHome: nativeHomeFromPlan(plan), + functions: functions, + observeMessages: req.ObserveMessages, observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, rpc: rpc, @@ -104,12 +72,11 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg cleanup: sync.OnceFunc(plan.Cleanup), bufs: NewItemBuffers(), resolvedModel: plan.Model, - interactions: newPendingCodexInteractions(), } 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_close_test.go b/apps/daemon/internal/agent/codex/preparation_close_test.go index 4abb0ce68..70cc5d769 100644 --- a/apps/daemon/internal/agent/codex/preparation_close_test.go +++ b/apps/daemon/internal/agent/codex/preparation_close_test.go @@ -43,3 +43,32 @@ func TestPreparedCloseWaitsForOwnerCleanup(t *testing.T) { } waitPreparedRelease(t, p, root) } + +func TestPreparedSessionCancellationDuringReadiness(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_PREPARATION_BLOCK", "1") + owner, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { + p, err := newPreparation(owner, req, cfg) + if p != nil { + _ = p.Close() + } + result <- err + }() + waitPreparationMethod(t, root, "environment/status") + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("cancelled readiness succeeded") + } + case <-time.After(4 * time.Second): + t.Fatal("cancelled readiness did not finish") + } + if len(preparedCatalogs(t, root)) != 0 { + t.Fatal("failed readiness leaked model catalog") + } + assertPreparationOnly(t, root) +} diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index d56acef77..2749dc255 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -28,7 +28,6 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig t.Setenv("OAC_TEST_PREPARATION_FRAMES", filepath.Join(root, "frames.jsonl")) t.Setenv("OAC_TEST_PREPARATION_STATUS", filepath.Join(root, "environment-status")) t.Setenv("OAC_TEST_PREPARATION_BLOCK", "") - t.Setenv("OAC_TEST_PREPARATION_OBSERVE", "") for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { t.Setenv(key, "") } @@ -42,8 +41,8 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig cfg.codexBinary = binary req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", - ReleaseOnCompletion: true, StrictResume: true, - AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}, + AgentOptions: map[string]any{"model": "fixture-model"}, + ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, DisableExecutionEnvironment: true, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"integer"}}}`)}}, } @@ -251,14 +250,6 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } if frame.Method == "turn/start" { _ = output.Encode(map[string]any{"jsonrpc": "2.0", "method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": "fixture-native-turn"}}}) - if os.Getenv("OAC_TEST_PREPARATION_OBSERVE") == "1" { - for _, raw := range []string{ - `{"method":"item/completed","params":{"threadId":"fixture-native-thread","turnId":"fixture-native-turn","item":{"type":"agentMessage","id":"message","text":"observed partial answer"}}}`, - `{"method":"thread/tokenUsage/updated","params":{"threadId":"fixture-native-thread","turnId":"fixture-native-turn","tokenUsage":{"total":{"inputTokens":30,"cachedInputTokens":4,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":40}}}}`, - } { - _ = output.Encode(json.RawMessage(raw)) - } - } } } _ = log.Close() diff --git a/apps/daemon/internal/agent/codex/preparation_observation.go b/apps/daemon/internal/agent/codex/preparation_observation.go index 95551beff..d006d39e5 100644 --- a/apps/daemon/internal/agent/codex/preparation_observation.go +++ b/apps/daemon/internal/agent/codex/preparation_observation.go @@ -14,6 +14,6 @@ func observePreparationStage(ctx context.Context, stage string, started time.Tim } func observePreparationInterval(ctx context.Context, stage string, started, ended time.Time, err error) { - obslog.Info(ctx, "codex preparation stage", "stage", stage, + obslog.Ctx(ctx).Info("codex preparation stage", "stage", stage, "duration_ms", float64(ended.Sub(started))/float64(time.Millisecond), "success", err == nil) } diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index 660c94504..35f2b64f0 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -4,7 +4,6 @@ import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" - "errors" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/google/uuid" @@ -59,9 +58,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary Factory must not run") - }) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}) prepared := make(chan *Prepared, 1) registry.RegisterExecutor("codex", func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { e, err := newExecutor(ctx, req, cfg) diff --git a/apps/daemon/internal/agent/codex/prepared.go b/apps/daemon/internal/agent/codex/prepared.go index 454b71976..d069e9311 100644 --- a/apps/daemon/internal/agent/codex/prepared.go +++ b/apps/daemon/internal/agent/codex/prepared.go @@ -1,90 +1,28 @@ package codex -import ( - "context" - "errors" - "strings" - "sync" +import "sync" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Prepared owns a connected native resource until Start transfers it to a Session. +// Prepared owns a connected native resource until an Executor takes it. // It observes owner cancellation and RPC exit, not continuous executor readiness. type Prepared struct { mu sync.Mutex session *Session plan SessionPlan resumeID string - strictResume bool requireExistingNativeSession bool - claimed bool - closed bool started bool transferred chan struct{} } -var _ agent.PreparedCancellation = (*Prepared)(nil) - -// Start consumes the preparation once. ctx bounds only this start operation; -// cancellation after return does not cancel the transferred Session. The original -// owner context remains its lifetime context. On success the Session owns out. -func (p *Prepared) Start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - session, err := p.start(ctx, runID, prompt, out) - if err != nil { - return nil, err - } - return session, nil -} - -func (p *Prepared) start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (*Session, error) { - if out == nil || strings.TrimSpace(runID) == "" || prompt.Validate() != nil { - return nil, errors.New("codex: start requires a run identity, prompt and output channel") - } - p.mu.Lock() - if p.claimed || p.closed { - p.mu.Unlock() - return nil, errors.New("codex: preparation is no longer available") - } - p.claimed = true - p.mu.Unlock() - - transferred := false - defer func() { - if !transferred { - _ = p.Close() - } - }() - p.mu.Lock() - defer p.mu.Unlock() - if p.closed || ctx.Err() != nil || p.session.cancelCtx.Err() != nil || !p.session.rpc.Alive() { - return nil, errors.New("codex: prepared harness is no longer available") - } - s := p.session - s.runID, s.out = runID, out - if s.observeSubagentIdentities { - s.startSubagentObservations() - } - s.registerHandlers() - p.started = true - close(p.transferred) - transferred = true - req := proto.PromptRequestPayload{RunID: runID, Input: prompt, AgentSessionID: p.resumeID, StrictResume: p.strictResume, RequireExistingNativeSession: p.requireExistingNativeSession} - go s.run(p.plan, req) - return s, nil -} - // Close waits for unused teardown and plan cleanup, including another caller's -// ongoing Close. After successful Start it is inert; use -// the returned Session's cancellation path to release the transferred resource. +// ongoing Close. After an Executor takes the resource it is inert; use +// Executor.Close to release it. func (p *Prepared) Close() error { p.mu.Lock() if p.started { p.mu.Unlock() return nil } - p.closed = true p.mu.Unlock() p.session.cancelFn() err := p.session.rpc.Close() @@ -94,31 +32,6 @@ func (p *Prepared) Close() error { return err } -// Cancel fences Start and cancels the resource even after transfer. -func (p *Prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - started := p.started - p.mu.Unlock() - if started { - if err := p.session.Cancel(ctx); err != nil { - return err - } - select { - case <-p.session.waitDone: - return nil - case <-ctx.Done(): - return ctx.Err() - } - } - return p.Close() -} - -// CancellationOutcome returns observed state, not a guarantee of final output or quiescence. -func (p *Prepared) CancellationOutcome() proto.DonePayload { - return p.session.CancellationOutcome() -} - func (p *Prepared) watchOwner() { select { case <-p.session.cancelCtx.Done(): diff --git a/apps/daemon/internal/agent/codex/prepared_cancel_test.go b/apps/daemon/internal/agent/codex/prepared_cancel_test.go deleted file mode 100644 index 3cc919e44..000000000 --- a/apps/daemon/internal/agent/codex/prepared_cancel_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package codex - -import ( - "context" - "errors" - "reflect" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestPreparedCancelUnusedWaitsForCleanup(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.AgentSessionID = "requested-but-unobserved-thread" - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - entered, release := make(chan struct{}), make(chan struct{}) - allowCleanup := sync.OnceFunc(func() { close(release) }) - defer allowCleanup() - cleanup := p.plan.Cleanup - var cleanups atomic.Int32 - p.plan.Cleanup = sync.OnceFunc(func() { - cleanups.Add(1) - close(entered) - <-release - cleanup() - }) - finished := make(chan error, 4) - for range 4 { - go func() { finished <- p.Cancel(context.Background()) }() - } - select { - case <-entered: - case <-time.After(4 * time.Second): - t.Fatal("cancellation did not begin cleanup") - } - out := make(chan proto.Envelope, 8) - if s, err := p.Start(t.Context(), "late", proto.TextInput("must not execute"), out); err == nil || s != nil { - t.Fatal("cancellation did not fence Start") - } - select { - case <-finished: - t.Fatal("cancellation returned before unused cleanup") - default: - } - allowCleanup() - for range 4 { - select { - case err := <-finished: - if err != nil { - t.Fatal(err) - } - case <-time.After(4 * time.Second): - t.Fatal("repeated cancellation did not finish") - } - } - if cleanups.Load() != 1 { - t.Fatal("cleanup ran more than once") - } - waitPreparedRelease(t, p, root) - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) -} - -func assertUnstartedCancellation(t *testing.T, p *Prepared) { - t.Helper() - got := p.CancellationOutcome() - if got.Content != "" || len(got.Metadata) != 0 || !reflect.DeepEqual(got.Usage, proto.Usage{}) { - t.Fatalf("unobserved result was invented: %+v", got) - } -} - -func TestPreparedCancelTransferredPreservesObservedOutcome(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_PREPARATION_OBSERVE", "1") - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - started, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 16)) - if err != nil { - t.Fatal(err) - } - s := started.(*Session) - deadline := time.Now().Add(4 * time.Second) - for { - got := p.CancellationOutcome() - if got.Content == "observed partial answer" && got.Usage.Tokens != nil { - break - } - if time.Now().After(deadline) { - t.Fatal("native output and Usage were not observed") - } - time.Sleep(time.Millisecond) - } - if err := p.Close(); err != nil || !s.rpc.Alive() { - t.Fatal("Close cancelled transferred Session", err) - } - var calls sync.WaitGroup - for range 4 { - calls.Go(func() { - if err := p.Cancel(context.Background()); err != nil { - t.Error(err) - } - }) - } - calls.Wait() - select { - case <-s.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("transferred Session did not finish") - } - waitPreparedRelease(t, p, root) - got := p.CancellationOutcome() - want := proto.TokenUsage{InputTokens: 30, CachedInputTokens: 4, OutputTokens: 10, ReasoningOutputTokens: 2, TotalTokens: 40} - if got.Content != "observed partial answer" || got.Metadata[proto.DoneMetaAgentSessionID] != "fixture-native-thread" || got.Usage.Tokens == nil || *got.Usage.Tokens != want { - t.Fatalf("observed outcome lost: %+v", got) - } - if !reflect.DeepEqual(got, s.CancellationOutcome()) { - t.Fatal("preparation and Session exposed different outcomes") - } - interrupts := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "turn/interrupt" { - interrupts++ - } - } - if interrupts != 1 { - t.Fatal("native cancellation was missing or repeated", interrupts) - } -} - -func TestPreparedCancelTransferredWaitsForCleanup(t *testing.T) { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - entered, release := make(chan struct{}), make(chan struct{}) - allowCleanup := sync.OnceFunc(func() { close(release) }) - defer allowCleanup() - cleanup := p.session.cleanup - p.session.cleanup = sync.OnceFunc(func() { close(entered); <-release; cleanup() }) - p.plan.Cleanup = p.session.cleanup - out := make(chan proto.Envelope, 16) - if _, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), out); err != nil { - t.Fatal(err) - } - waitPreparationMethod(t, root, "turn/start") - finished := make(chan error, 1) - go func() { finished <- p.Cancel(context.Background()) }() - select { - case <-entered: - case <-time.After(4 * time.Second): - t.Fatal("transferred cancellation did not begin cleanup") - } - for range out { - } - select { - case <-finished: - t.Fatal("cancellation returned after output closure but before local cleanup") - case <-p.session.waitDone: - t.Fatal("Session finished before local cleanup") - default: - } - ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer cancel() - if err := p.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("blocked cleanup ignored caller deadline: %v", err) - } - allowCleanup() - select { - case err := <-finished: - if err != nil { - t.Fatal(err) - } - case <-time.After(4 * time.Second): - t.Fatal("cancellation did not finish after local cleanup") - } - waitPreparedRelease(t, p, root) - if err := p.Cancel(t.Context()); err != nil { - t.Fatalf("settled cleanup could not be retried: %v", err) - } -} - -func TestPreparedCancelRacingTransfer(t *testing.T) { - for range 8 { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - begin := make(chan struct{}) - var calls sync.WaitGroup - calls.Go(func() { - <-begin - _, _ = p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 16)) - }) - calls.Go(func() { <-begin; _ = p.Cancel(context.Background()) }) - calls.Go(func() { <-begin; _ = p.Close() }) - close(begin) - calls.Wait() - if err := p.Cancel(context.Background()); err != nil { - t.Fatal(err) - } - if p.started { - select { - case <-p.session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("racing Session was retained") - } - } else { - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) - } - waitPreparedRelease(t, p, root) - if s, err := p.Start(t.Context(), "again", proto.TextInput("must not execute"), make(chan proto.Envelope, 8)); err == nil || s != nil { - t.Fatal("cancelled preparation started again") - } - } -} diff --git a/apps/daemon/internal/agent/codex/prepared_test.go b/apps/daemon/internal/agent/codex/prepared_test.go deleted file mode 100644 index e729c9613..000000000 --- a/apps/daemon/internal/agent/codex/prepared_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { - for _, resume := range []bool{false, true} { - t.Run(map[bool]string{false: "new", true: "resumed"}[resume], func(t *testing.T) { - req, cfg, root := preparationFixture(t) - if resume { - req.AgentSessionID = "fixture-native-thread" - } - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - assertPreparationOnly(t, root) - if len(preparedCatalogs(t, root)) != 1 { - t.Fatal("preparation did not retain its model catalog") - } - pid := p.session.rpc.cmd.Process.Pid - cfgPreparedCwd := p.plan.Cwd - // Caller-owned data cannot revise the prepared native configuration. - req.AgentOptions["model"] = "different-model" - req.AgentSessionID = "different-thread" - copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) - out := make(chan proto.Envelope, 8) - startCtx, stopStart := context.WithCancel(t.Context()) - started, err := p.Start(startCtx, "actual-run", proto.TextInput("actual prompt"), out) - stopStart() - if err != nil { - t.Fatal(err) - } - session := started.(*Session) - defer session.Cancel(context.Background()) - if session.rpc != p.session.rpc || session.rpc.cmd.Process.Pid != pid { - t.Fatal("start replaced the prepared native resource") - } - if err := p.Close(); err != nil || !session.rpc.Alive() { - t.Fatal("close cancelled transferred resource", err) - } - if again, err := p.Start(t.Context(), "second", proto.TextInput("second prompt"), out); err == nil || again != nil { - t.Fatal("preparation started twice", err) - } - frames := waitPreparationMethod(t, root, "turn/start") - counts := map[string]int{} - for _, frame := range frames { - counts[frame.Method]++ - if frame.PID != pid { - t.Fatal("preparation and start used different children") - } - var params struct { - Model string `json:"model"` - ThreadID string `json:"threadId"` - DynamicTools []dynamicFunctionTool `json:"dynamicTools"` - Cwd string `json:"cwd"` - Environments json.RawMessage `json:"environments"` - } - if err := json.Unmarshal(frame.Params, ¶ms); err != nil { - t.Fatal(err) - } - if frame.Method == "thread/start" { - if params.Model != "fixture-model" || len(params.DynamicTools) != 1 || !strings.Contains(string(params.DynamicTools[0].InputSchema), "integer") { - t.Fatal("prepared configuration changed", string(frame.Params)) - } - } - if frame.Method == "thread/resume" && params.ThreadID != "fixture-native-thread" { - t.Fatal("prepared resume changed") - } - if len(params.Environments) != 0 || (frame.Method == "thread/start" && params.Cwd != cfgPreparedCwd) || p.plan.Cwd != cfgPreparedCwd { - t.Fatal("prepared environment changed") - } - } - expectedThread := "thread/start" - if resume { - expectedThread = "thread/resume" - } - if counts["initialize"] != 1 || counts["environment/status"] != 2 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { - t.Fatal("unexpected native setup/start count", counts) - } - if err := session.Cancel(context.Background()); err != nil { - t.Fatal(err) - } - select { - case <-session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("started session did not release") - } - waitPreparedRelease(t, p, root) - }) - } -} - -func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { - for _, reason := range []string{"close", "owner cancelled", "rpc exited", "start cancelled"} { - t.Run(reason, func(t *testing.T) { - req, cfg, root := preparationFixture(t) - owner, cancelOwner := context.WithCancel(t.Context()) - defer cancelOwner() - p, err := newPreparation(owner, req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - startCtx := t.Context() - switch reason { - case "close": - if err := p.Close(); err != nil { - t.Fatal(err) - } - case "owner cancelled": - cancelOwner() - case "rpc exited": - if err := p.session.rpc.Close(); err != nil { - t.Fatal(err) - } - case "start cancelled": - var cancel context.CancelFunc - startCtx, cancel = context.WithCancel(t.Context()) - cancel() - } - if reason == "owner cancelled" || reason == "rpc exited" || reason == "close" { - // Release must happen without a later Start driving cleanup. - waitPreparedRelease(t, p, root) - } - out := make(chan proto.Envelope, 8) - if started, err := p.Start(startCtx, "late-run", proto.TextInput("must not start"), out); err == nil || started != nil { - t.Fatal("abandoned preparation started", err) - } - waitPreparedRelease(t, p, root) - assertPreparationOnly(t, root) - if len(out) != 0 { - t.Fatal("failed preparation emitted run output") - } - count := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "environment/status" { - count++ - } - } - if count != 2 { - t.Fatal("failed start reconnected the native environment", count) - } - }) - } -} - -func TestPreparedSessionConcurrentStartAndClose(t *testing.T) { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - begin := make(chan struct{}) - var wg sync.WaitGroup - started := make(chan *Session, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - <-begin - s, err := p.start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 8)) - if err == nil { - started <- s - } - }() - } - wg.Add(1) - go func() { defer wg.Done(); <-begin; _ = p.Close() }() - close(begin) - wg.Wait() - close(started) - count := 0 - for session := range started { - count++ - _ = session.Cancel(context.Background()) - select { - case <-session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("concurrent start retained a session") - } - } - if count > 1 { - t.Fatal("multiple ownership transfers", count) - } - waitPreparedRelease(t, p, root) - turns := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "turn/start" { - turns++ - } - } - if turns > 1 { - t.Fatal("multiple native Turns", turns) - } -} - -func TestPreparedSessionCancellationDuringReadiness(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_PREPARATION_BLOCK", "1") - owner, cancel := context.WithCancel(t.Context()) - defer cancel() - result := make(chan error, 1) - go func() { - p, err := newPreparation(owner, req, cfg) - if p != nil { - _ = p.Close() - } - result <- err - }() - waitPreparationMethod(t, root, "environment/status") - cancel() - select { - case err := <-result: - if err == nil { - t.Fatal("cancelled readiness succeeded") - } - case <-time.After(4 * time.Second): - t.Fatal("cancelled readiness did not finish") - } - if len(preparedCatalogs(t, root)) != 0 { - t.Fatal("failed readiness leaked model catalog") - } - assertPreparationOnly(t, root) -} 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..2daa4acb7 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -64,8 +64,8 @@ func (e *JsonRpcError) Error() string { return fmt.Sprintf("%d %s", e.Code, e.Me // --------------------------------------------------------------------------- // InitializeCapabilities mirrors codex-rs/app-server/src/protocol.rs. -// experimentalApi=true opts into the granular AskForApproval enum and -// the thread/* notification stream. +// experimentalApi=true opts into the experimental thread/* fields and +// notification stream. type InitializeCapabilities struct { ExperimentalAPI bool `json:"experimentalApi"` RequestAttestation bool `json:"requestAttestation"` @@ -93,83 +93,19 @@ type SkillsExtraRootsSetParams struct { ExtraRoots []string `json:"extraRoots"` } -// --------------------------------------------------------------------------- -// Approval / sandbox policies -// --------------------------------------------------------------------------- - -// GranularAskForApproval names each approval gate the codex agent can -// surface. All-false produces a fully silent run; turning any single -// field true makes the corresponding ServerRequest reach the daemon for -// human approval. -// -// JSON tags MUST stay snake_case — codex-rs deserialises this struct -// from a config TOML / RPC param with field names like sandbox_approval, -// and renaming silently breaks the wire. -type GranularAskForApproval struct { - SandboxApproval bool `json:"sandbox_approval"` - Rules bool `json:"rules"` - SkillApproval bool `json:"skill_approval"` - RequestPermissions bool `json:"request_permissions"` - MCPElicitations bool `json:"mcp_elicitations"` -} - -// AskForApproval is the discriminated union codex accepts on -// ThreadStartParams.approvalPolicy. The serialiser must emit EITHER -// {"granular": {...}} OR a bare string ("never" / "on-request" / -// "on-failure" / "untrusted"). See MarshalJSON in approval_policy.go. -type AskForApproval struct { - String string // "" when granular is set - Granular *GranularAskForApproval // nil when string is set -} - -// SandboxMode is the wire-level sandbox setting codex's v2 thread/start -// API accepts. Serializes to a kebab-case string per -// codex-rs/app-server-protocol/src/protocol/v2/shared.rs::SandboxMode. -// -// Earlier (~0.137-era) the field on ThreadStartParams was named -// `sandboxPolicy` and took a tagged object `{type: "dangerFullAccess"}`. -// 0.141.0 renamed it to `sandbox` and flattened it to one of these three -// strings. Sending the old shape no longer errors loudly — codex just -// silently falls back to read-only, which makes prompts terminate -// immediately with no agent output. Keep the constants pinned exactly -// to the kebab values upstream serializes. -type SandboxMode string - -const ( - SandboxReadOnly SandboxMode = "read-only" - SandboxWorkspaceWrite SandboxMode = "workspace-write" - SandboxDangerFullAcces SandboxMode = "danger-full-access" -) - -// SandboxPolicy is the legacy compound type kept for internal -// representation only — codex still echoes it back on some response -// shapes (turn_context inside rollout files, for example). It is NOT -// what ThreadStartParams.Sandbox takes on the wire. -type SandboxPolicy struct { - Type SandboxMode `json:"type"` - WritableRoots []string `json:"writableRoots,omitempty"` - NetworkAccess bool `json:"networkAccess,omitempty"` - ExcludeTmpdirEnvVar bool `json:"excludeTmpdirEnvVar,omitempty"` - ExcludeSlashTmp bool `json:"excludeSlashTmp,omitempty"` -} - // --------------------------------------------------------------------------- // thread/start, thread/resume, thread/list // --------------------------------------------------------------------------- type ThreadStartParams struct { - HistoryMode string `json:"historyMode,omitempty"` - Cwd string `json:"cwd"` - Model string `json:"model,omitempty"` - ModelProvider string `json:"modelProvider,omitempty"` - ApprovalPolicy AskForApproval `json:"approvalPolicy"` - // Sandbox is the v0.141+ field name; previously called sandboxPolicy - // and took a tagged-enum object. Wire format now is a kebab-case - // string: "read-only" / "workspace-write" / "danger-full-access". - // Sending the old object shape causes codex to silently default to - // read-only, which terminates the turn before the model can reply. - Sandbox SandboxMode `json:"sandbox,omitempty"` - Permissions string `json:"permissions,omitempty"` + HistoryMode string `json:"historyMode,omitempty"` + Cwd string `json:"cwd"` + Model string `json:"model,omitempty"` + ModelProvider string `json:"modelProvider,omitempty"` + ApprovalPolicy string `json:"approvalPolicy"` + // Sandbox is the kebab-case mode string. Codex silently falls back to + // read-only for the legacy sandboxPolicy object. + Sandbox string `json:"sandbox"` DeveloperInstructions string `json:"developerInstructions,omitempty"` RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots,omitempty"` DynamicTools []dynamicFunctionTool `json:"dynamicTools,omitempty"` @@ -187,19 +123,16 @@ type Thread struct { } type ThreadStartResult struct { - Thread Thread `json:"thread"` - Model string `json:"model,omitempty"` - ApprovalPolicy *AskForApproval `json:"approvalPolicy,omitempty"` - Sandbox *SandboxPolicy `json:"sandbox,omitempty"` + Thread Thread `json:"thread"` + Model string `json:"model,omitempty"` } type ThreadResumeParams struct { - Cwd string `json:"cwd,omitempty"` - DeveloperInstructions string `json:"developerInstructions"` - ThreadID string `json:"threadId"` - ApprovalPolicy AskForApproval `json:"approvalPolicy"` - Sandbox SandboxMode `json:"sandbox,omitempty"` - Permissions string `json:"permissions,omitempty"` + Cwd string `json:"cwd,omitempty"` + DeveloperInstructions string `json:"developerInstructions"` + ThreadID string `json:"threadId"` + ApprovalPolicy string `json:"approvalPolicy"` + Sandbox string `json:"sandbox"` } // --------------------------------------------------------------------------- @@ -238,10 +171,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"` @@ -384,84 +314,3 @@ type ErrorNotification struct { Error *TurnError `json:"error,omitempty"` Message string `json:"message,omitempty"` } - -// --------------------------------------------------------------------------- -// Approval and user-input ServerRequest params. Explicit requests defer their -// responses until Web or IM submits the decision, even with bypass defaults. -// --------------------------------------------------------------------------- - -type CommandExecutionRequestApprovalParams struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - ItemID string `json:"itemId"` - Command *string `json:"command,omitempty"` - Cwd *string `json:"cwd,omitempty"` - Reason *string `json:"reason,omitempty"` -} - -type FileChangeRequestApprovalParams struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - ItemID string `json:"itemId"` - Reason *string `json:"reason,omitempty"` - GrantRoot *string `json:"grantRoot,omitempty"` -} - -type PermissionsRequestApprovalParams struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - ItemID string `json:"itemId"` - Cwd string `json:"cwd"` - Reason *string `json:"reason,omitempty"` - Permissions map[string]any `json:"permissions,omitempty"` -} - -// CommandExecutionApprovalDecision is the verdict the daemon writes -// back to a Codex approval ServerRequest. "accept" / "decline" / "cancel" -// / "acceptForSession". -type CommandExecutionApprovalDecision = string - -// ApprovalDecisionResult is the result body for command-execution and -// file-change approvals. item/permissions/requestApproval uses the distinct -// PermissionsRequestApprovalResponse contract below. -type ApprovalDecisionResult struct { - Decision CommandExecutionApprovalDecision `json:"decision"` -} - -// PermissionsRequestApprovalResponse mirrors Codex app-server's dedicated -// response contract. Approving echoes the requested permission profile; -// denying returns an empty profile, which grants no additional capability. -type PermissionsRequestApprovalResponse struct { - Permissions map[string]any `json:"permissions"` - Scope string `json:"scope,omitempty"` -} - -type ToolRequestUserInputOption struct { - Label string `json:"label"` - Description string `json:"description"` -} - -type ToolRequestUserInputQuestion struct { - ID string `json:"id"` - Header string `json:"header"` - Question string `json:"question"` - Options []ToolRequestUserInputOption `json:"options,omitempty"` - IsOther bool `json:"isOther,omitempty"` - IsSecret bool `json:"isSecret,omitempty"` -} - -type ToolRequestUserInputParams struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - ItemID string `json:"itemId"` - Questions []ToolRequestUserInputQuestion `json:"questions"` - AutoResolutionMs *uint64 `json:"autoResolutionMs,omitempty"` -} - -type ToolRequestUserInputAnswer struct { - Answers []string `json:"answers"` -} - -type ToolRequestUserInputResponse struct { - Answers map[string]ToolRequestUserInputAnswer `json:"answers"` -} diff --git a/apps/daemon/internal/agent/codex/protocol_wire_test.go b/apps/daemon/internal/agent/codex/protocol_wire_test.go index 5a513043b..895888bab 100644 --- a/apps/daemon/internal/agent/codex/protocol_wire_test.go +++ b/apps/daemon/internal/agent/codex/protocol_wire_test.go @@ -6,66 +6,12 @@ import ( "testing" ) -// TestThreadStartParams_SandboxIsKebabString pins the v0.141+ wire -// format reviewer caught the hard way: codex's ThreadStartParams.sandbox -// is a kebab-case string ("read-only" / "workspace-write" / -// "danger-full-access"), NOT the legacy {"type":"dangerFullAccess"} -// tagged object on a sandboxPolicy field. -// -// Sending the legacy shape used to terminate every turn in <1s with an -// empty agent message body, because codex silently fell back to -// read-only when it didn't recognise the field name. -func TestThreadStartParams_SandboxIsKebabString(t *testing.T) { - cases := []struct { - name string - mode SandboxMode - want string - }{ - {"read-only", SandboxReadOnly, "read-only"}, - {"workspace-write", SandboxWorkspaceWrite, "workspace-write"}, - {"danger-full-access", SandboxDangerFullAcces, "danger-full-access"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - params := ThreadStartParams{ - Cwd: "/workspace", - Model: "gpt-5.5", - ApprovalPolicy: SilentGranularPolicy(), - Sandbox: tc.mode, - } - raw, err := json.Marshal(params) - if err != nil { - t.Fatalf("marshal: %v", err) - } - body := string(raw) - if !strings.Contains(body, `"sandbox":"`+tc.want+`"`) { - t.Fatalf("wire body missing sandbox=%q: %s", tc.want, body) - } - // The legacy field name MUST be gone. Even shipping it as - // "sandboxPolicy" alongside the new field would confuse - // future-version codex servers that strict-deserialise. - if strings.Contains(body, `"sandboxPolicy"`) { - t.Fatalf("legacy sandboxPolicy field leaked into wire: %s", body) - } - // And the kebab-case value must NOT collide with the old - // camelCase enum names. - for _, oldName := range []string{`"readOnly"`, `"workspaceWrite"`, `"dangerFullAccess"`} { - if strings.Contains(body, oldName) { - t.Fatalf("legacy camelCase enum value %s leaked: %s", oldName, body) - } - } - }) - } -} - // TestThreadStartParams_OmitsEmptyOptionalFields pins that model_provider // and other optional fields don't leak null/empty values into the wire. // codex would reject malformed enum values otherwise. func TestThreadStartParams_OmitsEmptyOptionalFields(t *testing.T) { params := ThreadStartParams{ - Cwd: "/workspace", - ApprovalPolicy: SilentGranularPolicy(), - Sandbox: SandboxDangerFullAcces, + Cwd: "/workspace", // Model, ModelProvider, DeveloperInstructions deliberately empty } raw, _ := json.Marshal(params) @@ -88,11 +34,9 @@ func TestThreadStartParams_OmitsEmptyOptionalFields(t *testing.T) { // override. Sending it on both paths is belt + suspenders. func TestThreadStartParams_ModelProviderIsCamelCaseField(t *testing.T) { params := ThreadStartParams{ - Cwd: "/workspace", - Model: "gpt-5.5", - ModelProvider: "oac", - ApprovalPolicy: SilentGranularPolicy(), - Sandbox: SandboxDangerFullAcces, + Cwd: "/workspace", + Model: "gpt-5.5", + ModelProvider: "oac", } raw, _ := json.Marshal(params) body := string(raw) @@ -106,13 +50,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 +68,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/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index 345a86378..80eb8d48b 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -156,13 +156,13 @@ func TestNormaliseProviderConfig_Nil(t *testing.T) { } func TestBuildSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { - plan, err := BuildSessionPlan("run-x", "conv-1/agent-1/codex", map[string]any{ + plan, err := BuildSessionPlan("conv-1/agent-1/codex", map[string]any{ "model": "fixture-model", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://x/v1", "api_key": "sk-x", }, - }) + }, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } @@ -194,7 +194,7 @@ func TestBuildSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { } func TestBuildSessionPlan_NoProviderLeavesBuiltinDefault(t *testing.T) { - plan, err := BuildSessionPlan("run-y", "conv-1/agent-1/codex", nil) + plan, err := BuildSessionPlan("conv-1/agent-1/codex", nil, nil) if err != nil { t.Fatalf("BuildSessionPlan: %v", err) } diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index da32ad87e..ebabf06b4 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -46,7 +46,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 } @@ -160,26 +160,20 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}, WorkspaceRoot: cwd} } - p, err := newPreparation(t.Context(), req, cfg) + e, err := testExecutor(t, "complete", req, cfg) if err != nil { t.Fatal(err) } - defer p.Close() - if p.plan.Cwd != cwd { - t.Fatalf("cwd = %q, want %q", p.plan.Cwd, cwd) + if e.prepared.plan.Cwd != cwd { + t.Fatalf("cwd = %q, want %q", e.prepared.plan.Cwd, cwd) } assertPreparationOnly(t, root) out := make(chan proto.Envelope, 16) - session, err := p.Start(t.Context(), "recovery-run", proto.TextInput("continue"), out) - if err != nil { - t.Fatal(err) - } - defer session.Cancel(context.Background()) - select { - case <-p.session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("recovery did not terminate") + turn, err := e.StartTurn(t.Context(), "recovery-run", proto.TextInput("continue"), out) + if err == nil { + t.Fatal("missing history started a Turn") } + settledFrames(t, turn, out) found := false for _, frame := range preparationFrames(t, root) { if frame.Method == "thread/list" { @@ -196,14 +190,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 016598da0..61cbae743 100644 --- a/apps/daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -184,7 +184,7 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (_ Ini spawnStarted := time.Now() process, err := clirunner.Start(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, }) observePreparationStage(ctx, "process_spawn", spawnStarted, err) if err != nil { diff --git a/apps/daemon/internal/agent/codex/rpc_close_test.go b/apps/daemon/internal/agent/codex/rpc_close_test.go index 80e404a85..7b387e23a 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) } @@ -80,7 +80,7 @@ func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { closeConcurrently(true) ownerCtx, cancelOwner := context.WithCancel(t.Context()) s := &Session{rpc: client, cancelCtx: ownerCtx, cancelFn: cancelOwner, - cfg: defaultSessionConfig(), interactions: newPendingCodexInteractions(), bufs: NewItemBuffers()} + cfg: defaultSessionConfig(), bufs: NewItemBuffers()} ctx, cancelWait := context.WithTimeout(t.Context(), 20*time.Millisecond) defer cancelWait() if err := s.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { diff --git a/apps/daemon/internal/agent/codex/server_requests.go b/apps/daemon/internal/agent/codex/server_requests.go deleted file mode 100644 index 5e77e3c9d..000000000 --- a/apps/daemon/internal/agent/codex/server_requests.go +++ /dev/null @@ -1,362 +0,0 @@ -package codex - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type pendingCodexPermission struct { - rpcID any - kind codexPermissionKind - permissions map[string]any - timeout time.Duration - timer *time.Timer -} - -type pendingCodexAsk struct { - rpcID any - questionIDs []string - timeout time.Duration - timer *time.Timer -} - -const codexInteractionTimeout = 10 * time.Minute - -type codexPermissionKind uint8 - -const ( - codexDecisionApproval codexPermissionKind = iota - codexPermissionsApproval - codexMCPApproval -) - -type pendingCodexInteractions struct { - mu sync.Mutex - permissions map[string]pendingCodexPermission - asks map[string]pendingCodexAsk -} - -func newPendingCodexInteractions() *pendingCodexInteractions { - return &pendingCodexInteractions{ - permissions: make(map[string]pendingCodexPermission), - asks: make(map[string]pendingCodexAsk), - } -} - -func codexInteractionID(prefix string) string { - var bytes [8]byte - if _, err := rand.Read(bytes[:]); err == nil { - return prefix + "_" + hex.EncodeToString(bytes[:]) - } - return fmt.Sprintf("%s_fallback", prefix) -} - -func (s *Session) handleCodexCommandApproval(raw json.RawMessage, rpcID any) (any, error) { - var params CommandExecutionRequestApprovalParams - if err := json.Unmarshal(raw, ¶ms); err != nil { - return nil, fmt.Errorf("decode command approval: %w", err) - } - title := stringPointer(params.Command) - if title == "" { - title = "Run command" - } - return s.deferCodexPermission(rpcID, codexDecisionApproval, "command_execution", title, stringPointer(params.Reason), raw, nil) -} - -func (s *Session) handleCodexFileApproval(raw json.RawMessage, rpcID any) (any, error) { - var params FileChangeRequestApprovalParams - if err := json.Unmarshal(raw, ¶ms); err != nil { - return nil, fmt.Errorf("decode file approval: %w", err) - } - title := stringPointer(params.GrantRoot) - if title == "" { - title = "Apply file changes" - } - return s.deferCodexPermission(rpcID, codexDecisionApproval, "file_change", title, stringPointer(params.Reason), raw, nil) -} - -func (s *Session) handleCodexPermissionsApproval(raw json.RawMessage, rpcID any) (any, error) { - var params PermissionsRequestApprovalParams - if err := json.Unmarshal(raw, ¶ms); err != nil { - return nil, fmt.Errorf("decode permissions approval: %w", err) - } - title := strings.TrimSpace(params.Cwd) - if title == "" { - title = "Grant additional permissions" - } - return s.deferCodexPermission(rpcID, codexPermissionsApproval, "permission_request", title, stringPointer(params.Reason), raw, params.Permissions) -} - -func (s *Session) deferCodexPermission(rpcID any, kind codexPermissionKind, tool, title, detail string, raw json.RawMessage, permissions map[string]any) (any, error) { - requestID := codexInteractionID("perm") - payload := map[string]any{} - _ = json.Unmarshal(raw, &payload) - pending := pendingCodexPermission{ - rpcID: rpcID, kind: kind, permissions: permissions, - timeout: codexInteractionTimeout, - } - s.interactions.mu.Lock() - // Start the timer while holding the table lock. Even a future very short - // timeout then blocks in expireCodexPermission until the entry is visible, - // rather than firing before insertion and leaving an immortal request. - pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexPermission(requestID) }) - s.interactions.permissions[requestID] = pending - s.interactions.mu.Unlock() - env, err := proto.NewEnvelope(proto.TypePermissionRequest, s.runID, proto.PermissionRequestPayload{ - RequestID: requestID, Tool: tool, Title: title, Detail: detail, Payload: payload, - }) - if err != nil { - s.interactions.mu.Lock() - pending, ok := s.interactions.permissions[requestID] - if ok { - delete(s.interactions.permissions, requestID) - } - s.interactions.mu.Unlock() - if ok && pending.timer != nil { - pending.timer.Stop() - } - return nil, err - } - s.trySend(env) - return DeferReply, nil -} - -func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, error) { - var params ToolRequestUserInputParams - if err := json.Unmarshal(raw, ¶ms); err != nil { - return nil, fmt.Errorf("decode requestUserInput: %w", err) - } - if len(params.Questions) == 0 { - return nil, errors.New("requestUserInput contains no questions") - } - askID := codexInteractionID("ask") - questions := make([]proto.PromptForUserChoiceQuestion, 0, len(params.Questions)) - questionIDs := make([]string, 0, len(params.Questions)) - for index, question := range params.Questions { - options := make([]proto.PromptForUserChoiceOption, 0, len(question.Options)) - for _, option := range question.Options { - options = append(options, proto.PromptForUserChoiceOption{Label: option.Label, Description: option.Description}) - } - header := strings.TrimSpace(question.Header) - if header == "" { - header = fmt.Sprintf("q%d", index) - } - questionID := strings.TrimSpace(question.ID) - if questionID == "" { - questionID = header - } - questionIDs = append(questionIDs, questionID) - questions = append(questions, proto.PromptForUserChoiceQuestion{ - ID: questionID, Header: header, Question: question.Question, Options: options, - IsOther: question.IsOther, IsSecret: question.IsSecret, - }) - } - timeout := codexInteractionTimeout - if params.AutoResolutionMs != nil && *params.AutoResolutionMs > 0 { - const maxMillis = uint64(^uint64(0)>>1) / uint64(time.Millisecond) - if *params.AutoResolutionMs <= maxMillis { - timeout = time.Duration(*params.AutoResolutionMs) * time.Millisecond - } - } - pending := pendingCodexAsk{rpcID: rpcID, questionIDs: questionIDs, timeout: timeout} - s.interactions.mu.Lock() - pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) - s.interactions.asks[askID] = pending - s.interactions.mu.Unlock() - env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, s.runID, proto.PromptForUserChoicePayload{ - AskID: askID, Questions: questions, AutoResolutionMs: params.AutoResolutionMs, - }) - if err != nil { - s.interactions.mu.Lock() - pending, ok := s.interactions.asks[askID] - if ok { - delete(s.interactions.asks, askID) - } - s.interactions.mu.Unlock() - if ok && pending.timer != nil { - pending.timer.Stop() - } - return nil, err - } - s.trySend(env) - return DeferReply, nil -} - -func (s *Session) submitCodexPermission(requestID string, decision proto.PermissionDecisionPayload) error { - if !s.beginOperation() { - return agent.ErrSteeringInactive - } - defer s.endOperation() - s.interactions.mu.Lock() - pending, ok := s.interactions.permissions[requestID] - if ok { - delete(s.interactions.permissions, requestID) - } - s.interactions.mu.Unlock() - if !ok { - return agent.ErrUnknownPermission - } - if pending.timer != nil { - pending.timer.Stop() - } - if err := s.sendCodexPermissionReply(pending, decision.Approved); err != nil { - s.interactions.mu.Lock() - pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexPermission(requestID) }) - s.interactions.permissions[requestID] = pending - s.interactions.mu.Unlock() - return err - } - return nil -} - -func (s *Session) sendCodexPermissionReply(pending pendingCodexPermission, approved bool) error { - if pending.kind == codexPermissionsApproval { - permissions := map[string]any{} - if approved && pending.permissions != nil { - permissions = pending.permissions - } - return s.rpc.SendServerReply(pending.rpcID, PermissionsRequestApprovalResponse{ - Permissions: permissions, - Scope: "turn", - }) - } - value := "decline" - if approved { - value = "accept" - } - if pending.kind == codexMCPApproval { - var content map[string]any - if approved { - content = map[string]any{} - } - return s.rpc.SendServerReply(pending.rpcID, mcpElicitationResponse{Action: value, Content: content}) - } - return s.rpc.SendServerReply(pending.rpcID, ApprovalDecisionResult{Decision: value}) -} - -func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUserChoiceDecisionPayload) error { - if !s.beginOperation() { - return agent.ErrSteeringInactive - } - defer s.endOperation() - s.interactions.mu.Lock() - pending, ok := s.interactions.asks[askID] - var answers map[string][]string - if ok { - var err error - answers, err = decision.AnswersFor(pending.questionIDs) - if err != nil { - s.interactions.mu.Unlock() - return err - } - delete(s.interactions.asks, askID) - } - s.interactions.mu.Unlock() - if !ok { - return agent.ErrUnknownAsk - } - if pending.timer != nil { - pending.timer.Stop() - } - if decision.Cancelled { - reason := strings.TrimSpace(decision.Reason) - if reason == "" { - reason = "user cancelled input request" - } - if err := s.rpc.SendServerError(pending.rpcID, -32001, reason, nil); err != nil { - s.interactions.mu.Lock() - pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) - s.interactions.asks[askID] = pending - s.interactions.mu.Unlock() - return err - } - return nil - } - result := ToolRequestUserInputResponse{Answers: make(map[string]ToolRequestUserInputAnswer, len(pending.questionIDs))} - for _, questionID := range pending.questionIDs { - values := answers[questionID] - if values == nil { - values = []string{} - } - result.Answers[questionID] = ToolRequestUserInputAnswer{Answers: values} - } - if err := s.rpc.SendServerReply(pending.rpcID, result); err != nil { - s.interactions.mu.Lock() - pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) - s.interactions.asks[askID] = pending - s.interactions.mu.Unlock() - return err - } - return nil -} - -func (s *Session) expireCodexPermission(requestID string) { - if !s.beginOperation() { - return - } - defer s.endOperation() - s.interactions.mu.Lock() - pending, ok := s.interactions.permissions[requestID] - if ok { - delete(s.interactions.permissions, requestID) - } - s.interactions.mu.Unlock() - if ok { - if pending.timer != nil { - pending.timer.Stop() - } - _ = s.sendCodexPermissionReply(pending, false) - } -} - -func (s *Session) expireCodexAsk(askID string) { - if !s.beginOperation() { - return - } - defer s.endOperation() - s.interactions.mu.Lock() - pending, ok := s.interactions.asks[askID] - if ok { - delete(s.interactions.asks, askID) - } - s.interactions.mu.Unlock() - if ok { - if pending.timer != nil { - pending.timer.Stop() - } - _ = s.rpc.SendServerError(pending.rpcID, -32001, "input request timed out", nil) - } -} - -func (s *Session) stopCodexInteractionTimers() { - s.interactions.mu.Lock() - defer s.interactions.mu.Unlock() - for id, pending := range s.interactions.permissions { - if pending.timer != nil { - pending.timer.Stop() - } - delete(s.interactions.permissions, id) - } - for id, pending := range s.interactions.asks { - if pending.timer != nil { - pending.timer.Stop() - } - delete(s.interactions.asks, id) - } -} - -func stringPointer(value *string) string { - if value == nil { - return "" - } - return strings.TrimSpace(*value) -} diff --git a/apps/daemon/internal/agent/codex/server_requests_mcp.go b/apps/daemon/internal/agent/codex/server_requests_mcp.go deleted file mode 100644 index 4cb4de00e..000000000 --- a/apps/daemon/internal/agent/codex/server_requests_mcp.go +++ /dev/null @@ -1,38 +0,0 @@ -package codex - -import ( - "encoding/json" - "errors" - "fmt" - "strings" -) - -type mcpElicitationParams struct { - ServerName string `json:"serverName"` - Mode string `json:"mode"` - Message string `json:"message"` - Schema struct { - Type string `json:"type"` - Properties map[string]json.RawMessage `json:"properties"` - Required []string `json:"required"` - } `json:"requestedSchema"` -} - -type mcpElicitationResponse struct { - Action string `json:"action"` - Content map[string]any `json:"content"` -} - -func (s *Session) handleCodexMCPElicitation(raw json.RawMessage, rpcID any) (any, error) { - var params mcpElicitationParams - if err := json.Unmarshal(raw, ¶ms); err != nil { - return nil, fmt.Errorf("decode MCP elicitation: %w", err) - } - if params.Mode != "form" || params.Schema.Type != "object" || params.Schema.Properties == nil || len(params.Schema.Properties) != 0 || len(params.Schema.Required) != 0 { - return nil, errors.New("MCP elicitation requires unsupported input; only empty confirmation forms are supported") - } - if strings.TrimSpace(params.ServerName) == "" || strings.TrimSpace(params.Message) == "" { - return nil, errors.New("MCP confirmation requires a server name and message") - } - return s.deferCodexPermission(rpcID, codexMCPApproval, "mcp:"+params.ServerName, params.Message, "", raw, nil) -} diff --git a/apps/daemon/internal/agent/codex/server_requests_mcp_test.go b/apps/daemon/internal/agent/codex/server_requests_mcp_test.go deleted file mode 100644 index a72ec3d9e..000000000 --- a/apps/daemon/internal/agent/codex/server_requests_mcp_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package codex - -import ( - "encoding/json" - "errors" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestCodexMCPConfirmationUsesPermissionLifecycle(t *testing.T) { - for _, action := range []string{"approve", "deny", "expire"} { - t.Run(action, func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - defer s.stopCodexInteractionTimers() - params := json.RawMessage(`{"threadId":"thread-1","turnId":"turn-1","serverName":"qa-service-desk","mode":"form","message":"Allow get_test_ticket?","requestedSchema":{"type":"object","properties":{}},"_meta":{"codex_approval_kind":"mcp_tool_call","persist":["session","always"]}}`) - if err := SendServerRequest(srv, "rpc-mcp", "mcpServer/elicitation/request", params); err != nil { - t.Fatal(err) - } - var env proto.Envelope - select { - case env = <-out: - case <-time.After(2 * time.Second): - t.Fatal("MCP confirmation was not surfaced") - } - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatal(err) - } - if env.Type != proto.TypePermissionRequest || env.ID != "run-test" || request.RequestID == "" || request.Tool != "mcp:qa-service-desk" || request.Title != "Allow get_test_ticket?" { - t.Fatalf("incorrect confirmation: %+v / %+v", env, request) - } - done := make(chan error, 1) - go func() { - if action == "expire" { - s.expireCodexPermission(request.RequestID) - done <- nil - return - } - done <- s.SubmitPermission(t.Context(), request.RequestID, proto.PermissionDecisionPayload{Approved: action == "approve"}) - }() - var reply struct { - ID string `json:"id"` - Result map[string]json.RawMessage `json:"result"` - } - decodeCodexReply(t, srv, &reply) - if err := <-done; err != nil { - t.Fatal(err) - } - wantAction, wantContent := `"decline"`, `null` - if action == "approve" { - wantAction, wantContent = `"accept"`, `{}` - } - if reply.ID != "rpc-mcp" || len(reply.Result) != 2 || string(reply.Result["action"]) != wantAction || string(reply.Result["content"]) != wantContent { - t.Fatalf("incorrect MCP response: %+v", reply) - } - if err := s.SubmitPermission(t.Context(), request.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { - t.Fatalf("resolved confirmation accepted again: %v", err) - } - }) - } -} - -func TestCodexMCPElicitationRejectsUnsupportedInputWithoutApproval(t *testing.T) { - for _, params := range []string{ - `{"serverName":"qa","mode":"form","message":"Enter name","requestedSchema":{"type":"object","properties":{"name":{"type":"string"}}}}`, - `{"serverName":"qa","mode":"url","message":"Sign in","url":"https://example.test/login","elicitationId":"login"}`, - `{"serverName":"qa","mode":"form","message":"Confirm","requestedSchema":{"type":"object","properties":{},"required":["name"]}}`, - `{"serverName":"qa","mode":"form","message":"Confirm","requestedSchema":{"type":"object"}}`, - `{"mode":"form","requestedSchema":{"type":"object","properties":{}}}`, - } { - t.Run(params, func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - defer s.stopCodexInteractionTimers() - if err := SendServerRequest(srv, "rpc-unsupported", "mcpServer/elicitation/request", json.RawMessage(params)); err != nil { - t.Fatal(err) - } - var reply struct { - Error *struct { - Code int `json:"code"` - } `json:"error"` - } - decodeCodexReply(t, srv, &reply) - if reply.Error == nil || reply.Error.Code != -32603 { - t.Fatalf("unsupported input was not rejected: %+v", reply) - } - select { - case env := <-out: - t.Fatalf("unsupported input became an approval: %+v", env) - default: - } - }) - } -} diff --git a/apps/daemon/internal/agent/codex/server_requests_test.go b/apps/daemon/internal/agent/codex/server_requests_test.go deleted file mode 100644 index 4bae35ed0..000000000 --- a/apps/daemon/internal/agent/codex/server_requests_test.go +++ /dev/null @@ -1,357 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func newInteractionTestSession(rpc *JSONRPCClient) (*Session, <-chan proto.Envelope) { - out := make(chan proto.Envelope, 1) - ctx, cancel := context.WithCancel(context.Background()) - s := &Session{ - runID: "run-test", - out: out, - rpc: rpc, - cancelCtx: ctx, - cancelFn: cancel, - interactions: newPendingCodexInteractions(), - } - s.registerHandlers() - return s, out -} - -func TestCodexPermissionRequestWaitsForHumanDecision(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - - command := "go test ./..." - reason := "requires process execution" - if err := SendServerRequest(srv, "rpc-perm-1", "item/commandExecution/requestApproval", CommandExecutionRequestApprovalParams{ - ThreadID: "thread-1", TurnID: "turn-1", ItemID: "item-1", Command: &command, Reason: &reason, - }); err != nil { - t.Fatalf("send server request: %v", err) - } - - var env proto.Envelope - select { - case env = <-out: - case <-time.After(2 * time.Second): - t.Fatal("permission request was not surfaced to OpenAgentCore") - } - if env.Type != proto.TypePermissionRequest { - t.Fatalf("envelope type = %q, want %q", env.Type, proto.TypePermissionRequest) - } - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode permission payload: %v", err) - } - if env.ID != "run-test" || request.RequestID == "" { - t.Fatalf("permission correlation = env.ID %q request.ID %q", env.ID, request.RequestID) - } - if request.Tool != "command_execution" || request.Title != command || request.Detail != reason { - t.Fatalf("permission payload = %+v", request) - } - - submitDone := make(chan error, 1) - go func() { - submitDone <- s.SubmitPermission(context.Background(), request.RequestID, proto.PermissionDecisionPayload{Approved: true}) - }() - reply := readCodexServerReply(t, srv) - if err := <-submitDone; err != nil { - t.Fatalf("submit permission: %v", err) - } - if reply.ID != "rpc-perm-1" || reply.Result.Decision != "accept" { - t.Fatalf("reply = %+v", reply) - } -} - -func TestCodexPermissionsApprovalUsesDedicatedResponseSchema(t *testing.T) { - for _, tt := range []struct { - name string - approved bool - wantGrants bool - }{ - {name: "approve echoes requested grants", approved: true, wantGrants: true}, - {name: "deny grants nothing", approved: false, wantGrants: false}, - } { - t.Run(tt.name, func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - requested := map[string]any{ - "network": map[string]any{"enabled": true}, - "fileSystem": map[string]any{"write": []any{"/workspace"}}, - } - if err := SendServerRequest(srv, "rpc-permissions", "item/permissions/requestApproval", PermissionsRequestApprovalParams{ - Cwd: "/workspace", Permissions: requested, - }); err != nil { - t.Fatalf("send permissions request: %v", err) - } - env := <-out - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode permission payload: %v", err) - } - done := make(chan error, 1) - go func() { - done <- s.SubmitPermission(context.Background(), request.RequestID, proto.PermissionDecisionPayload{Approved: tt.approved}) - }() - var reply struct { - ID string `json:"id"` - Result struct { - Permissions map[string]any `json:"permissions"` - Scope string `json:"scope"` - Decision string `json:"decision"` - } `json:"result"` - } - decodeCodexReply(t, srv, &reply) - if err := <-done; err != nil { - t.Fatalf("submit permissions: %v", err) - } - if reply.Result.Scope != "turn" || reply.Result.Decision != "" { - t.Fatalf("permissions reply = %+v", reply.Result) - } - _, granted := reply.Result.Permissions["network"] - if granted != tt.wantGrants { - t.Fatalf("permissions = %+v, want grants=%v", reply.Result.Permissions, tt.wantGrants) - } - }) - } -} - -func TestCodexUserInputMapsAnswersByQuestionID(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - - autoResolutionMs := uint64(120_000) - if err := SendServerRequest(srv, "rpc-ask-1", "item/tool/requestUserInput", ToolRequestUserInputParams{ - ThreadID: "thread-1", TurnID: "turn-1", ItemID: "item-ask", - AutoResolutionMs: &autoResolutionMs, - Questions: []ToolRequestUserInputQuestion{ - {ID: "deployment", Header: "Deploy", Question: "Where?", IsOther: true, Options: []ToolRequestUserInputOption{{Label: "Staging"}, {Label: "Production"}}}, - {ID: "checks", Header: "Checks", Question: "Which checks?", IsSecret: true, Options: []ToolRequestUserInputOption{{Label: "Unit"}, {Label: "E2E"}}}, - }, - }); err != nil { - t.Fatalf("send server request: %v", err) - } - - var env proto.Envelope - select { - case env = <-out: - case <-time.After(2 * time.Second): - t.Fatal("requestUserInput was not surfaced to OpenAgentCore") - } - if env.Type != proto.TypePromptForUserChoice { - t.Fatalf("envelope type = %q, want %q", env.Type, proto.TypePromptForUserChoice) - } - var request proto.PromptForUserChoicePayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode user input payload: %v", err) - } - if len(request.Questions) != 2 || request.Questions[0].ID != "deployment" || request.Questions[1].ID != "checks" || request.Questions[0].Header != "Deploy" { - t.Fatalf("user input payload = %+v", request) - } - if !request.Questions[0].IsOther || !request.Questions[1].IsSecret || request.AutoResolutionMs == nil || *request.AutoResolutionMs != autoResolutionMs { - t.Fatalf("user input metadata = %+v", request) - } - s.interactions.mu.Lock() - pending := s.interactions.asks[request.AskID] - s.interactions.mu.Unlock() - if pending.timeout != 2*time.Minute { - t.Fatalf("ask timeout = %v, want 2m", pending.timeout) - } - - if err := s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "foreign", Answers: []string{"yes"}}}, - }); err == nil { - t.Fatal("accepted a foreign question ID") - } - // Invalid identity must leave the original native request pending. - submitDone := make(chan error, 1) - go func() { - submitDone <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ - {QuestionID: "checks", Answers: []string{"Unit", "E2E"}}, - {QuestionID: "deployment", Answers: []string{"Staging"}}, - }, - }) - }() - - var reply struct { - ID string `json:"id"` - Result ToolRequestUserInputResponse `json:"result"` - } - decodeCodexReply(t, srv, &reply) - if err := <-submitDone; err != nil { - t.Fatalf("submit user input: %v", err) - } - if reply.ID != "rpc-ask-1" { - t.Fatalf("reply id = %q", reply.ID) - } - if got := reply.Result.Answers["deployment"].Answers; len(got) != 1 || got[0] != "Staging" { - t.Fatalf("deployment answers = %v", got) - } - if got := reply.Result.Answers["checks"].Answers; len(got) != 2 || got[0] != "Unit" || got[1] != "E2E" { - t.Fatalf("checks answers = %v", got) - } -} - -func TestCodexUserInputCancellationReturnsErrorInsteadOfEmptyAnswers(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - - if err := SendServerRequest(srv, "rpc-ask-cancel", "item/tool/requestUserInput", ToolRequestUserInputParams{ - Questions: []ToolRequestUserInputQuestion{{ID: "confirm", Header: "Confirm", Question: "Continue?"}}, - }); err != nil { - t.Fatalf("send server request: %v", err) - } - var env proto.Envelope - select { - case env = <-out: - case <-time.After(2 * time.Second): - t.Fatal("requestUserInput was not surfaced to OpenAgentCore") - } - var request proto.PromptForUserChoicePayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode user input payload: %v", err) - } - submitDone := make(chan error, 1) - go func() { - submitDone <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ - Cancelled: true, Reason: "cancelled by user", - }) - }() - - var reply struct { - ID string `json:"id"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - decodeCodexReply(t, srv, &reply) - if err := <-submitDone; err != nil { - t.Fatalf("cancel user input: %v", err) - } - if reply.ID != "rpc-ask-cancel" || reply.Error == nil || reply.Error.Code != -32001 { - t.Fatalf("cancel reply = %+v", reply) - } -} - -func TestCodexInteractionExpiryUnblocksRuntime(t *testing.T) { - t.Run("permission declines", func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - command := "deploy" - if err := SendServerRequest(srv, "rpc-perm-timeout", "item/commandExecution/requestApproval", CommandExecutionRequestApprovalParams{Command: &command}); err != nil { - t.Fatalf("send permission request: %v", err) - } - env := <-out - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode permission payload: %v", err) - } - expireDone := make(chan struct{}) - go func() { s.expireCodexPermission(request.RequestID); close(expireDone) }() - reply := readCodexServerReply(t, srv) - <-expireDone - if reply.Result.Decision != "decline" { - t.Fatalf("expiry decision = %q", reply.Result.Decision) - } - if err := s.SubmitPermission(context.Background(), request.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { - t.Fatalf("late permission error = %v, want ErrUnknownPermission", err) - } - }) - - t.Run("permission profile grants nothing", func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - if err := SendServerRequest(srv, "rpc-profile-timeout", "item/permissions/requestApproval", PermissionsRequestApprovalParams{ - Permissions: map[string]any{"network": map[string]any{"enabled": true}}, - }); err != nil { - t.Fatalf("send permission profile request: %v", err) - } - env := <-out - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode permission payload: %v", err) - } - done := make(chan struct{}) - go func() { s.expireCodexPermission(request.RequestID); close(done) }() - var reply struct { - Result PermissionsRequestApprovalResponse `json:"result"` - } - decodeCodexReply(t, srv, &reply) - <-done - if len(reply.Result.Permissions) != 0 || reply.Result.Scope != "turn" { - t.Fatalf("expiry permissions response = %+v", reply.Result) - } - }) - - t.Run("user input returns timeout error", func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - if err := SendServerRequest(srv, "rpc-ask-timeout", "item/tool/requestUserInput", ToolRequestUserInputParams{ - Questions: []ToolRequestUserInputQuestion{{ID: "q1", Header: "Confirm", Question: "Continue?"}}, - }); err != nil { - t.Fatalf("send input request: %v", err) - } - var request proto.PromptForUserChoicePayload - if err := (<-out).DecodePayload(&request); err != nil { - t.Fatalf("decode input request: %v", err) - } - expireDone := make(chan struct{}) - go func() { s.expireCodexAsk(request.AskID); close(expireDone) }() - var reply struct { - Error *struct { - Code int `json:"code"` - } `json:"error"` - } - decodeCodexReply(t, srv, &reply) - <-expireDone - if reply.Error == nil || reply.Error.Code != -32001 { - t.Fatalf("expiry reply = %+v", reply) - } - if err := s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}); !errors.Is(err, agent.ErrUnknownAsk) { - t.Fatalf("late input error = %v, want ErrUnknownAsk", err) - } - }) -} - -type approvalReply struct { - ID string `json:"id"` - Result ApprovalDecisionResult `json:"result"` -} - -func readCodexServerReply(t *testing.T, srv ServerSide) approvalReply { - t.Helper() - var reply approvalReply - decodeCodexReply(t, srv, &reply) - return reply -} - -func decodeCodexReply(t *testing.T, srv ServerSide, target any) { - t.Helper() - done := make(chan error, 1) - go func() { done <- json.NewDecoder(srv.FromClient).Decode(target) }() - select { - case err := <-done: - if err != nil { - t.Fatalf("decode Codex reply: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("Codex reply timed out") - } -} diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 9b8ce23b0..9d3386609 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -20,8 +20,8 @@ import ( // adapter safety net. const terminalSendTimeout = 2 * time.Second -// sessionConfig is the cross-cutting knob bag — production callers go -// through Factory which uses defaults. +// sessionConfig is the cross-cutting knob bag; production callers use +// defaultSessionConfig. type sessionConfig struct { codexBinary string logger *slog.Logger @@ -36,14 +36,6 @@ 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. -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultSessionConfig()) -} - // Session implements agent.Session. State lifecycle: // // 1. Preparation initializes RPC and verifies the selected environment. @@ -70,11 +62,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 @@ -112,24 +103,11 @@ type Session struct { finalText string lastErrText string - interactions *pendingCodexInteractions - outcome cancellationOutcomeState + outcome cancellationOutcomeState } var _ agent.Session = (*Session)(nil) -// SubmitPermission completes the deferred Codex app-server request that -// produced the Core permission envelope. -func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { - return s.submitCodexPermission(permID, decision) -} - -// SubmitPromptForUserChoice maps Core's header/answer pairs back to -// Codex's question-id keyed requestUserInput response. -func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { - return s.submitCodexUserInput(askID, decision) -} - // --------------------------------------------------------------------------- // notification handlers // --------------------------------------------------------------------------- @@ -151,14 +129,10 @@ func (s *Session) registerHandlers() { rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) - s.onServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) - s.onServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) - s.onServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) - // Older app-server releases used this unseparated method name. - s.onServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) - s.onServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) + // Harnesses run unattended. Approval policy never and the session plan keep + // native asks from being raised; any other server request gets the + // client's method-not-found reply. s.onServerRequest("item/tool/call", s.handleFunctionCall) - s.onServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) } func (s *Session) onTurnStarted(raw json.RawMessage) { diff --git a/apps/daemon/internal/agent/codex/session_cancel.go b/apps/daemon/internal/agent/codex/session_cancel.go index 25024d5b5..091eae119 100644 --- a/apps/daemon/internal/agent/codex/session_cancel.go +++ b/apps/daemon/internal/agent/codex/session_cancel.go @@ -42,7 +42,6 @@ func (s *Session) cancelNativeWork() { defer close(s.cancelReady) s.stopFunctionCalls() turnID, active := s.stopSteering() - s.stopCodexInteractionTimers() // Best effort: a known Turn must use its native identity. An explicit // empty ID invokes native startup cancellation before turn/started. if threadID := s.currentThreadID(); threadID != "" && active { diff --git a/apps/daemon/internal/agent/codex/session_cancel_test.go b/apps/daemon/internal/agent/codex/session_cancel_test.go index b5879197b..32e794b6a 100644 --- a/apps/daemon/internal/agent/codex/session_cancel_test.go +++ b/apps/daemon/internal/agent/codex/session_cancel_test.go @@ -116,7 +116,7 @@ func cancellationTestSession(t *testing.T) (*Session, *TestClient, ServerSide) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cancelFn: cancel, - cfg: defaultSessionConfig(), interactions: newPendingCodexInteractions(), out: make(chan proto.Envelope, 8), bufs: NewItemBuffers()} + cfg: defaultSessionConfig(), out: make(chan proto.Envelope, 8), bufs: NewItemBuffers()} return s, client, server } diff --git a/apps/daemon/internal/agent/codex/session_cancel_write_test.go b/apps/daemon/internal/agent/codex/session_cancel_write_test.go index 7c0eed406..087b8e78f 100644 --- a/apps/daemon/internal/agent/codex/session_cancel_write_test.go +++ b/apps/daemon/internal/agent/codex/session_cancel_write_test.go @@ -146,7 +146,7 @@ func TestCancelReleasesBlockedNativeProcess(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() s := &Session{rpc: client, cancelCtx: ctx, cancelFn: cancel, - cfg: defaultSessionConfig(), interactions: newPendingCodexInteractions(), bufs: NewItemBuffers()} + cfg: defaultSessionConfig(), bufs: NewItemBuffers()} s.setThreadID("native-thread") s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) cancelDone := make(chan struct{}) 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 0fde0a6e0..d8e7aede8 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -10,18 +10,17 @@ import ( ) func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, []string, error) { - if err := validateNativeTransportEnvironment(req); err != nil { + 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) if err != nil { return SessionPlan{}, nil, err } - plan, err := BuildSessionPlan(req.RunID, req.AgentStateKey, req.AgentOptions) + plan, err := BuildSessionPlan(req.AgentStateKey, req.AgentOptions, req.ExecutionControls) if err != nil { return SessionPlan{}, nil, fmt.Errorf("codex: build session plan: %w", err) } @@ -32,10 +31,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg disableProgrammaticTools(&plan, req.ExecutionControls) if req.LocalEnvironment != nil { plan.Cwd = req.LocalEnvironment.WorkspaceRoot - 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 = nativeHomeFromPlan(plan) } @@ -64,7 +60,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } } - if stringOpt(req.AgentOptions, "model_verbosity") != "" { + if req.ExecutionControls != nil { if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { plan.Cleanup() return SessionPlan{}, nil, err @@ -76,23 +72,14 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } var skillRoots []string if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { - err = verifyHostedSkills(req.LocalEnvironment.Skills) - if err == nil { - for _, skill := range req.LocalEnvironment.Skills { - skillRoots = append(skillRoots, localworkspace.SkillPath(skill)) - } + if err := verifyHostedSkills(req.LocalEnvironment.Skills); err != nil { + plan.Cleanup() + return SessionPlan{}, nil, err } - } else if !req.DisableExecutionEnvironment { - var root string - root, err = prepareManagedSkills(ctx, cfg.logger, req) - if root != "" { - skillRoots = []string{root} + for _, skill := range req.LocalEnvironment.Skills { + skillRoots = append(skillRoots, localworkspace.SkillPath(skill)) } } - if err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } plan.Env = append(plan.Env, mcpEnv...) diff --git a/apps/daemon/internal/agent/codex/session_policy_test.go b/apps/daemon/internal/agent/codex/session_policy_test.go index e21e793c9..86c5b3c74 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{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{}) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{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..3394acf90 100644 --- a/apps/daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -9,29 +9,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { - defer close(s.waitDone) - defer s.stopCodexInteractionTimers() - defer s.stopFunctionCalls() - defer s.cleanup() - defer s.closeRunOutput() - - if err := s.startNative(s.cancelCtx, plan, req); err != nil { - s.emitTerminal(err.Error(), true) - return - } - - // Block until terminal handlers close the RPC child or cancellation arrives. - select { - case <-s.rpc.Done(): - if !s.cancelled.Load() && s.cancelCtx.Err() == nil { - s.emitTerminal("codex: connection closed before the run completed", true) - } - case <-s.cancelCtx.Done(): - _ = s.rpc.Close() - } -} - func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.PromptRequestPayload) error { if s.currentThreadID() == "" { if err := s.resolveThread(req, plan); err != nil { @@ -47,23 +24,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..6dc663ea0 100644 --- a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -83,10 +83,11 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out := make(chan proto.Envelope, 8) + turnCtx, cancelTurn := context.WithCancel(ctx) s := &Session{ - runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, - cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), cleanup: func() {}, - bufs: NewItemBuffers(), interactions: newPendingCodexInteractions(), + executor: &Executor{}, runID: "run", rpc: client.JSONRPCClient, cancelCtx: turnCtx, cancelFn: cancelTurn, out: out, resolvedModel: "synthetic", + cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, + bufs: NewItemBuffers(), } s.registerHandlers() ready := make(chan error, 1) @@ -119,7 +120,8 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { } ready <- nil }() - go s.run(SessionPlan{Model: "synthetic"}, proto.PromptRequestPayload{Input: proto.TextInput("first")}) + // Executor.StartTurn starts and settles each Turn through these two steps. + go s.settleExecutorTurn(s.startNative(ctx, SessionPlan{Model: "synthetic"}, proto.PromptRequestPayload{Input: proto.TextInput("first")})) if err := <-ready; err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session_thread.go b/apps/daemon/internal/agent/codex/session_thread.go index 28681b400..35699b8a8 100644 --- a/apps/daemon/internal/agent/codex/session_thread.go +++ b/apps/daemon/internal/agent/codex/session_thread.go @@ -5,14 +5,20 @@ import ( "fmt" ) +// Harnesses run unattended with the launching user's permissions: Codex never +// raises a native approval request and applies no inner sandbox. +const ( + approvalPolicyNever = "never" + sandboxDangerFullAccess = "danger-full-access" +) + func (s *Session) startThread(plan SessionPlan) error { params := ThreadStartParams{ Cwd: plan.Cwd, Model: plan.Model, ModelProvider: plan.ModelProvider, - ApprovalPolicy: plan.ApprovalPolicy, - Sandbox: plan.Sandbox, - Permissions: plan.Permissions, + ApprovalPolicy: approvalPolicyNever, + Sandbox: sandboxDangerFullAccess, DeveloperInstructions: plan.SystemPrompt, } if s.observeSubagentIdentities { @@ -26,8 +32,7 @@ func (s *Session) startThread(plan SessionPlan) error { "cwd", params.Cwd, "model", params.Model, "model_provider", params.ModelProvider, - "sandbox", string(params.Sandbox), - "approval_silent", IsSilent(¶ms.ApprovalPolicy), + "sandbox", params.Sandbox, "developer_instructions_len", len(params.DeveloperInstructions)) _, err := s.rpc.requestWithResult(s.cancelCtx, "thread/start", params, func(raw json.RawMessage) error { return s.bindThreadResult(raw, "") @@ -37,8 +42,7 @@ func (s *Session) startThread(plan SessionPlan) error { func (s *Session) resumeThread(threadID string, plan SessionPlan) error { params := ThreadResumeParams{ - ThreadID: threadID, ApprovalPolicy: plan.ApprovalPolicy, Sandbox: plan.Sandbox, - Permissions: plan.Permissions, + ThreadID: threadID, ApprovalPolicy: approvalPolicyNever, Sandbox: sandboxDangerFullAccess, DeveloperInstructions: plan.SystemPrompt, } if plan.mcpServers != nil { 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 aacb6d0ca..f7bdfe04a 100644 --- a/apps/daemon/internal/agent/codex/skills.go +++ b/apps/daemon/internal/agent/codex/skills.go @@ -1,47 +1,6 @@ package codex -import ( - "context" - "fmt" - "log/slog" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" - "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 "" -} - -func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (string, error) { - rawSkills := req.AgentOptions["skills"] - root, err := agent.ManagedSkillsRoot("codex", req.AgentStateKey, req.ConversationID, req.RunID) - if err != nil { - return "", fmt.Errorf("codex: resolve managed skills root: %w", err) - } - result, err := managedskills.InstallManagedSkills(ctx, logger, root, rawSkills) - if err != nil { - return "", fmt.Errorf("codex: install skills: %w", err) - } - for _, warning := range result.Warnings { - logger.Warn("codex: skill install warning", "run_id", req.RunID, "msg", warning) - } - if len(result.SkillDirs) == 0 { - return "", nil - } - return root, nil -} +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 35d031ba5..bc316eed9 100644 --- a/apps/daemon/internal/agent/codex/skills_test.go +++ b/apps/daemon/internal/agent/codex/skills_test.go @@ -3,11 +3,7 @@ package codex import ( "context" "encoding/json" - "os" - "path/filepath" "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { @@ -42,35 +38,3 @@ func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { t.Fatalf("setSkillExtraRoots: %v", err) } } - -func TestPrepareManagedSkillsPrunesWhenPayloadOmitsSkills(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - stale := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills", "stale") - if err := os.MkdirAll(stale, 0o755); err != nil { - t.Fatal(err) - } - - root, err := prepareManagedSkills(context.Background(), nil, proto.PromptRequestPayload{ - AgentStateKey: "conv-1/agent-1/codex", - }) - if err != nil { - t.Fatalf("prepareManagedSkills: %v", err) - } - if root != "" { - t.Fatalf("root = %q, want empty", root) - } - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("stale skill still exists: %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_observations_test.go b/apps/daemon/internal/agent/codex/subagent_observations_test.go index 6d10dd0a8..3e8f7241a 100644 --- a/apps/daemon/internal/agent/codex/subagent_observations_test.go +++ b/apps/daemon/internal/agent/codex/subagent_observations_test.go @@ -74,7 +74,7 @@ func observationSession(t *testing.T, status string) (*Session, *subagentFixture t.Fatal(err) } f.persist(t) - s := &Session{runID: "run", nativeHome: f.home, rpc: client.JSONRPCClient, out: out, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers(), interactions: newPendingCodexInteractions()} + s := &Session{runID: "run", nativeHome: f.home, rpc: client.JSONRPCClient, out: out, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} s.setThreadID("root") s.beginRootTurn("root", "root-turn") s.startSubagentObservations() 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/unsupported.go b/apps/daemon/internal/agent/codex/unsupported.go index a2a092928..3da26fb7f 100644 --- a/apps/daemon/internal/agent/codex/unsupported.go +++ b/apps/daemon/internal/agent/codex/unsupported.go @@ -28,15 +28,3 @@ func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.Wo func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *Prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/codex/unsupported_test.go b/apps/daemon/internal/agent/codex/unsupported_test.go index 0f0936197..fe2fe3616 100644 --- a/apps/daemon/internal/agent/codex/unsupported_test.go +++ b/apps/daemon/internal/agent/codex/unsupported_test.go @@ -24,21 +24,21 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) if len(result.Data) != 0 || result.Truncated { t.Fatal("unsupported read fabricated data") } } - for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil)} { result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) check(err) if len(result.Entries) != 0 || result.Truncated { t.Fatal("unsupported listing fabricated entries") } } - for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/codex/verbosity_test.go b/apps/daemon/internal/agent/codex/verbosity_test.go deleted file mode 100644 index 924cc52e3..000000000 --- a/apps/daemon/internal/agent/codex/verbosity_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package codex - -import ( - "reflect" - "testing" -) - -func TestVerbosityConfiguration(t *testing.T) { - for _, mode := range []string{"", "low", "medium", "high"} { - t.Run(mode, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - opts := map[string]any{} - var want [][2]string - if mode != "" { - opts["model_verbosity"] = mode - want = [][2]string{{"model_verbosity", `"` + mode + `"`}} - } - plan, err := BuildSessionPlan("run", "state", opts) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if !reflect.DeepEqual(plan.ExtraConfig, want) { - t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) - } - }) - } - for _, value := range []any{nil, "", "enabled", true, 1, map[string]any{}, []any{}} { - if _, err := BuildSessionPlan("run", "state", map[string]any{"model_verbosity": value}); err == nil { - t.Fatalf("accepted invalid model_verbosity %T", value) - } - } -} diff --git a/apps/daemon/internal/agent/codex/version.go b/apps/daemon/internal/agent/codex/version.go index e79627206..432c8399f 100644 --- a/apps/daemon/internal/agent/codex/version.go +++ b/apps/daemon/internal/agent/codex/version.go @@ -51,7 +51,7 @@ func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { if err != nil { status = "error" } - obslog.Info(ctx, "runtime version probe", "harness_kind", "codex", "stage", "process_spawn", "duration_ms", float64(time.Since(spawnAt))/float64(time.Millisecond), "status", status) + obslog.Ctx(ctx).Info("runtime version probe", "harness_kind", "codex", "stage", "process_spawn", "duration_ms", float64(time.Since(spawnAt))/float64(time.Millisecond), "status", status) if err == nil { waitAt := time.Now() err = cmd.Wait() @@ -59,15 +59,15 @@ func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { if err != nil { status = "error" } - obslog.Info(ctx, "runtime version probe", "harness_kind", "codex", "stage", "process_wait", "duration_ms", float64(completedAt.Sub(waitAt))/float64(time.Millisecond), "status", status) + obslog.Ctx(ctx).Info("runtime version probe", "harness_kind", "codex", "stage", "process_wait", "duration_ms", float64(completedAt.Sub(waitAt))/float64(time.Millisecond), "status", status) // Wait joins the stdout copier. Inspect its timestamp only after it returns; // receiving bytes never replaces process exit or output validation. if !stdout.first.IsZero() { - obslog.Info(ctx, "runtime version probe", "harness_kind", "codex", "stage", "first_stdout", "duration_ms", float64(stdout.first.Sub(spawnAt))/float64(time.Millisecond)) - obslog.Info(ctx, "runtime version probe", "harness_kind", "codex", "stage", "stdout_to_completion", "duration_ms", float64(completedAt.Sub(stdout.first))/float64(time.Millisecond), "status", status) + obslog.Ctx(ctx).Info("runtime version probe", "harness_kind", "codex", "stage", "first_stdout", "duration_ms", float64(stdout.first.Sub(spawnAt))/float64(time.Millisecond)) + obslog.Ctx(ctx).Info("runtime version probe", "harness_kind", "codex", "stage", "stdout_to_completion", "duration_ms", float64(completedAt.Sub(stdout.first))/float64(time.Millisecond), "status", status) } if cmd.ProcessState != nil { - obslog.Info(ctx, "runtime version probe resources", "harness_kind", "codex", + obslog.Ctx(ctx).Info("runtime version probe resources", "harness_kind", "codex", "user_cpu_ms", float64(cmd.ProcessState.UserTime())/float64(time.Millisecond), "system_cpu_ms", float64(cmd.ProcessState.SystemTime())/float64(time.Millisecond)) } diff --git a/apps/daemon/internal/agent/codex/web_search_test.go b/apps/daemon/internal/agent/codex/web_search_test.go deleted file mode 100644 index 43fc00de9..000000000 --- a/apps/daemon/internal/agent/codex/web_search_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package codex - -import ( - "reflect" - "testing" -) - -func TestWebSearchConfiguration(t *testing.T) { - for _, mode := range []string{"", "disabled", "cached", "live"} { - t.Run(mode, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - opts := map[string]any{} - var want [][2]string - if mode != "" { - opts["web_search"] = mode - want = [][2]string{{"web_search", `"` + mode + `"`}} - } - plan, err := BuildSessionPlan("run", "state", opts) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if !reflect.DeepEqual(plan.ExtraConfig, want) { - t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) - } - }) - } - for _, value := range []any{nil, "", "enabled", true, 1, map[string]any{}, []any{}} { - if _, err := BuildSessionPlan("run", "state", map[string]any{"web_search": value}); err == nil { - t.Fatalf("accepted invalid web_search %T", value) - } - } -} diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index 974f06824..2d36d96da 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -17,54 +17,26 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { configuration := harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "responses"}}} calls := 0 expected := errors.New("native entry reached") - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, configuration, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - calls++ - return nil, expected - }) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, configuration) registry.RegisterExecutor("fixture", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { calls++ return nil, expected }) - registry.RegisterPreparation("fixture", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - calls++ - 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") - preparation, _ := registry.ResolvePreparation("fixture") - entries := []func(proto.PromptRequestPayload) error{ - func(req proto.PromptRequestPayload) error { _, err := factory(t.Context(), req, nil); return err }, - func(req proto.PromptRequestPayload) error { _, err := executor(t.Context(), req); return err }, - func(req proto.PromptRequestPayload) error { _, err := preparation(t.Context(), req); return err }, - } - for _, entry := range entries { - for _, options := range []map[string]any{ - {"model": nil}, - {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"harness_config": map[string]any{"unknown": "private-sentinel"}}, - } { - before := calls - err := entry(proto.PromptRequestPayload{AgentOptions: options}) - if err == nil || errors.Is(err, expected) || calls != before || strings.Contains(err.Error(), "private-sentinel") { - t.Fatal("invalid configuration reached native entry or leaked values") - } + for _, options := range []map[string]any{ + {"model": nil}, + {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, + {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, + {"harness_config": map[string]any{"unknown": "private-sentinel"}}, + } { + _, err := executor(t.Context(), proto.PromptRequestPayload{AgentOptions: options}) + if err == nil || errors.Is(err, expected) || calls != 0 || strings.Contains(err.Error(), "private-sentinel") { + t.Fatal("invalid configuration reached native entry or leaked values") } - if err := entry(proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) { - t.Fatal("bound declaration was lost or mutated", err) - } - } - if calls != 3 { - t.Fatal("unexpected native calls", calls) } - if _, err := registry.Configuration("missing"); err == nil { - t.Fatal("undeclared configuration inferred") + if _, err := executor(t.Context(), proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) || calls != 1 { + t.Fatal("bound declaration was lost or mutated", err) } } @@ -74,5 +46,5 @@ func TestRegistryRejectsInvalidConfigurationDeclaration(t *testing.T) { t.Fatal("invalid configuration registered") } }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "unknown"}}}, stubFactory("fixture")) + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "unknown"}}}) } diff --git a/apps/daemon/internal/agent/contract_declarations_test.go b/apps/daemon/internal/agent/contract_declarations_test.go index fc642d3f4..7915f9df6 100644 --- a/apps/daemon/internal/agent/contract_declarations_test.go +++ b/apps/daemon/internal/agent/contract_declarations_test.go @@ -22,13 +22,9 @@ func TestPublicHarnessContractDeclarations(t *testing.T) { "DurableSteerer": {"session"}, "Steerer": {"session"}, "FunctionResultSubmitter": {"session"}, - "PermissionResponder": {"session"}, - "UserChoiceResponder": {"session"}, - "WorkspaceReader": {"executor", "prepared", "session"}, - "WorkspaceDirectoryLister": {"executor", "prepared", "session"}, - "WorkspaceWriter": {"executor", "prepared", "session"}, - "Prepared": {"prepared"}, - "PreparedCancellation": {"prepared"}, + "WorkspaceReader": {"executor", "session"}, + "WorkspaceDirectoryLister": {"executor", "session"}, + "WorkspaceWriter": {"executor", "session"}, } files, err := filepath.Glob("*.go") if err != nil { diff --git a/apps/daemon/internal/agent/contracttest/text.go b/apps/daemon/internal/agent/contracttest/text.go index 5c0d76e4a..c389163a7 100644 --- a/apps/daemon/internal/agent/contracttest/text.go +++ b/apps/daemon/internal/agent/contracttest/text.go @@ -32,7 +32,7 @@ type TextFixture struct { // TextLifecycle verifies ordinary reuse, exact Turn ownership, durable active // input, confirmed cancellation and continuation of a healthy Executor. It asks -// for no optional tools, images, interactions, workspace or detailed Usage. +// for no optional tools, images, workspace or detailed Usage. // Native fault injection and history recovery have separate adapter tests. func TextLifecycle(t *testing.T, fixture TextFixture) { t.Helper() diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 7d0f715dd..23f8d37e7 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -14,8 +14,8 @@ // Registration: each adapter exports one Declaration. The Runtime discovers the // static declaration list and installs each resulting Runtime through Register. // Availability and factory selection belong to the adapter. RegisterKind resets -// the other factories, so Register installs it first. Preparation capabilities -// are derived from the declared factories. +// the factories, so Register installs it first. RegisterExecutor derives the +// Preparation capability; the adapter declares WorkspaceReadPreparation. // // Runtime registration and Core service qualification remain separate. A public // Harness also needs a profile in services/core/internal/engine; advertising @@ -35,7 +35,7 @@ import ( // Declaration is the complete startup contract for a Harness implementation. // Discover returns nil when the adapter is not configured. An unavailable -// configured adapter returns a Runtime with Available=false and a session factory. +// configured adapter returns a Runtime with Available=false and no factories. // Discovery owns runtime-specific configuration, readiness and feature gates. type Declaration struct { Info proto.SupportedAgentKind @@ -50,17 +50,9 @@ type DiscoveryOptions struct { } // Runtime binds one discovered descriptor to its native factories. -// SessionCapabilityContext and ExecutorCapabilityContext request the Runtime's -// capability-download URL and scoped product-upload context for those factories. -// Preparation never receives those execution-only effects. type Runtime struct { - Info proto.SupportedAgentKind - Session Factory - Preparation PreparationFactory - Executor ExecutorFactory - WorkspaceReadPreparation bool - SessionCapabilityContext bool - ExecutorCapabilityContext bool + Info proto.SupportedAgentKind + Executor ExecutorFactory } // Register installs a discovered Runtime with its declaration's configuration. @@ -68,18 +60,18 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { if runtime.Info.Kind != declaration.Info.Kind { panic("agent.Registry.Register: discovery kind differs from declaration") } - r.RegisterKind(runtime.Info, declaration.Configuration, runtime.Session) + if !runtime.Info.Available && runtime.Executor != nil { + panic("agent.Registry.Register: unavailable runtime has factories") + } + r.RegisterKind(runtime.Info, declaration.Configuration) if runtime.Executor != nil { r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) } - if runtime.Preparation != nil { - r.RegisterPreparation(runtime.Info.Kind, runtime.WorkspaceReadPreparation, runtime.Preparation) - } } // Model configuration has one shared contract, authored in // internal/harnessconfig/harness.go. RegisterKind requires that declaration; -// RegisterExecutor and RegisterPreparation inherit it. Every registered entry +// RegisterExecutor inherits it. Every registered entry // validates model, provider and native parameters before calling native code. // The declaration belongs to the adapter and is also consumed by Core. Keep // adapter field rules and rendering private. That shared contract owns frozen @@ -112,8 +104,8 @@ type Executor interface { type Turn interface { Session DurableSteerer - // Success confirms closed output and settled native input, function, - // interaction and child-work obligations. Errors cannot prove cancellation. + // Success confirms closed output and settled native input, function and + // child-work obligations. Errors cannot prove cancellation. AwaitSettlement(context.Context) (TurnSettlement, error) } @@ -124,8 +116,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 of a Turn. 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 { @@ -161,20 +153,6 @@ type FunctionResultSubmitter interface { SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error } -// PermissionResponder accepts decisions for qualified permission requests. -// An adapter that never supports these interactions returns ErrUnsupportedOperation. -// Unknown or expired requests return ErrUnknownPermission. -type PermissionResponder interface { - SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error -} - -// UserChoiceResponder accepts answers for qualified user-choice requests. -// An adapter that never supports these interactions returns ErrUnsupportedOperation. -// Unknown or expired requests return ErrUnknownAsk. -type UserChoiceResponder interface { - SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error -} - // Workspace extensions, implemented by each owner explicitly. The common // Runtime may supply an authorized workspace owner independently of the adapter. // A resource without native access returns the corresponding Unsupported error; @@ -204,52 +182,16 @@ type WorkspaceWriter interface { WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) } -// Separate preparation for qualified workspace access and direct-call paths. - -// Prepared owns native resources until Start returns a non-nil Session. The -// preparation owner context spans the eventual Session; Start's context is local -// to that operation. A nil Session leaves preparation cleanup with the caller. -type Prepared interface { - // Start transfers output ownership only when it returns a non-nil Session. - // A nil Session leaves the caller as the sole owner of closing out, and the - // implementation must not retain or write to it after Start returns. - Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Session, error) - // Close retains unused ownership on error; callers may retry settlement. - Close() error -} +// Kind registration. -// PreparedCancellation is required for executable preparations and follows the -// same native resource across Start. Read-only preparations need only Prepared. -type PreparedCancellation interface { - Prepared - Session - // Cancel returns after local cleanup and all output writes have stopped. - // An error retains ownership so callers can retry this exact object serially. - Cancel(context.Context) error -} - -// A factory may return both a resource and an error when construction failed but -// cleanup remains unconfirmed. The caller must retain and close that resource. -type PreparationFactory func(context.Context, proto.PromptRequestPayload) (Prepared, error) - -// Direct-call factory and registration. These use the existing Registry behavior. - -// 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. -type Factory func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (Session, error) - -// RegisterKind installs f and the heartbeat descriptor for an -// agent_kind. Callers may set Available=false when an adapter exists -// but its underlying CLI is not usable. -func (r *Registry) RegisterKind(info proto.SupportedAgentKind, configuration harnessconfig.Configuration, f Factory) { +// RegisterKind installs the heartbeat descriptor and model configuration for an +// agent_kind. Callers may set Available=false when an adapter exists but its +// underlying CLI is not usable. +func (r *Registry) RegisterKind(info proto.SupportedAgentKind, configuration harnessconfig.Configuration) { kind := info.Kind if kind == "" { panic("agent.Registry.Register: empty kind") } - if f == nil { - panic("agent.Registry.Register: nil factory") - } if err := configuration.ValidateDeclaration(); err != nil { panic(err) } @@ -260,16 +202,8 @@ func (r *Registry) RegisterKind(info proto.SupportedAgentKind, configuration har r.mu.Lock() defer r.mu.Unlock() r.configurations[kind] = configuration - r.factories[kind] = func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (Session, error) { - if _, err := configuration.Prepare(req.AgentOptions); err != nil { - return nil, err - } - return f(ctx, req, out) - } - delete(r.preparers, kind) delete(r.executors, kind) info.Capabilities.Preparation = proto.CapabilityUnsupported - info.Capabilities.WorkspaceReadPreparation = proto.CapabilityUnsupported r.kinds[kind] = info } @@ -295,27 +229,3 @@ func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { info.Capabilities.Preparation = proto.CapabilitySupported r.kinds[kind] = info } - -// RegisterPreparation installs a separate execution-only path. Product factory -// wrappers must not add authoring or capability-download side effects to it. -func (r *Registry) RegisterPreparation(kind string, workspaceRead bool, prepare PreparationFactory) { - r.mu.Lock() - defer r.mu.Unlock() - info, exists := r.kinds[kind] - if !exists || prepare == nil { - panic("agent.Registry.RegisterPreparation: registered kind and factory required") - } - configuration, declared := r.configurations[kind] - if !declared { - panic("agent.Registry.RegisterPreparation: configuration required") - } - r.preparers[kind] = func(ctx context.Context, req proto.PromptRequestPayload) (Prepared, error) { - if _, err := configuration.Prepare(req.AgentOptions); err != nil { - return nil, err - } - return prepare(ctx, req) - } - info.Capabilities.Preparation = proto.CapabilitySupported - info.Capabilities.WorkspaceReadPreparation = proto.CapabilityFromBool(workspaceRead) - r.kinds[kind] = info -} diff --git a/apps/daemon/internal/agent/installroot/lock.go b/apps/daemon/internal/agent/installroot/lock.go deleted file mode 100644 index 19d9295e4..000000000 --- a/apps/daemon/internal/agent/installroot/lock.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package installroot coordinates adapter installations within one daemon process. -package installroot - -import ( - "context" - "os" - "sync" -) - -type installRootLock struct { - info os.FileInfo - token chan struct{} - users int -} - -var installRoots = struct { - sync.Mutex - entries map[*installRootLock]struct{} -}{entries: make(map[*installRootLock]struct{})} - -// Lock creates the install root if needed and holds its filesystem identity -// until the returned function is called once. -// Waiting callers may cancel without interrupting the current installation. -func Lock(ctx context.Context, root string) (func(), error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := os.MkdirAll(root, 0o755); err != nil { - return nil, err - } - info, err := os.Stat(root) - if err != nil { - return nil, err - } - installRoots.Lock() - var entry *installRootLock - for candidate := range installRoots.entries { - if os.SameFile(candidate.info, info) { - entry = candidate - break - } - } - if entry == nil { - entry = &installRootLock{info: info, token: make(chan struct{}, 1)} - entry.token <- struct{}{} - installRoots.entries[entry] = struct{}{} - } - entry.users++ - installRoots.Unlock() - - release := func() { - installRoots.Lock() - entry.users-- - if entry.users == 0 { - delete(installRoots.entries, entry) - } - installRoots.Unlock() - } - select { - case <-ctx.Done(): - release() - return nil, ctx.Err() - case <-entry.token: - return func() { - entry.token <- struct{}{} - release() - }, nil - } -} diff --git a/apps/daemon/internal/agent/installroot/lock_test.go b/apps/daemon/internal/agent/installroot/lock_test.go deleted file mode 100644 index 4359af299..000000000 --- a/apps/daemon/internal/agent/installroot/lock_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package installroot - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestLockCaseAliases(t *testing.T) { - parent := t.TempDir() - alias := strings.ToUpper(parent) - realInfo, err := os.Stat(parent) - if err != nil { - t.Fatal(err) - } - aliasInfo, err := os.Stat(alias) - if err != nil || !os.SameFile(realInfo, aliasInfo) { - t.Skip("filesystem does not expose this case alias") - } - root := filepath.Join(parent, "new-root") - unlock, err := Lock(context.Background(), root) - if err != nil { - t.Fatal(err) - } - defer unlock() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) - defer cancel() - release, err := Lock(ctx, filepath.Join(alias, "NEW-ROOT")) - if err == nil { - release() - t.Fatal("case alias acquired an independent lock") - } - if err != context.DeadlineExceeded { - t.Fatalf("lock error = %v", err) - } -} diff --git a/apps/daemon/internal/agent/installroot/probe.go b/apps/daemon/internal/agent/installroot/probe.go index b185c31bc..d2932e3f8 100644 --- a/apps/daemon/internal/agent/installroot/probe.go +++ b/apps/daemon/internal/agent/installroot/probe.go @@ -1,3 +1,4 @@ +// Package installroot probes native adapter installations. package installroot import ( @@ -14,7 +15,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/managedskills/archive.go b/apps/daemon/internal/agent/managedskills/archive.go deleted file mode 100644 index 363f8b0da..000000000 --- a/apps/daemon/internal/agent/managedskills/archive.go +++ /dev/null @@ -1,289 +0,0 @@ -package managedskills - -import ( - "archive/zip" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" -) - -// pluginInstallTimeout caps a single plugin's download + extract step. -const pluginInstallTimeout = 60 * time.Second - -// maxPluginZipBytes mirrors the server-side cap in -// server/internal/capability/parser/plugin_validator.go. Defense in -// depth. -const maxPluginZipBytes int64 = 32 * 1024 * 1024 - -// pluginsHTTPClient timeout is larger than pluginInstallTimeout so the -// per-call context cancel dominates. -var pluginsHTTPClient = &http.Client{ - Timeout: pluginInstallTimeout + 10*time.Second, -} - -// fetchPluginZip GETs url into dst, capping the body at -// maxPluginZipBytes. Returns an OPEN file descriptor positioned at -// offset 0; the caller closes it. Holding the FD across verify + -// extract closes the TOCTOU between hashing the on-disk bytes and -// reading them for extract — even if someone swaps the file, the open -// FD points at the original inode. -// -// Only http/https are accepted to defend against a future -// canonical_spec letting attacker-supplied download_url reach this -// code with file:// or http://internal-ip/... values. -func fetchPluginZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { - parsed, err := url.Parse(downloadURL) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { - // Don't include downloadURL — it carries the signature query - // string. - return nil, errors.New("download_url must be http(s)") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if err != nil { - return nil, errors.New("build request failed") - } - resp, err := pluginsHTTPClient.Do(req) - if err != nil { - // Strip embedded URL via sanitizeHTTPClientError — - // OSSAccessKeyId + Signature would otherwise leak into the - // daemon log. - return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) - return nil, fmt.Errorf("get: status %d", resp.StatusCode) - } - - // O_EXCL — the per-call uuid in the path makes a collision a - // programmer error, not an attacker condition. Failing fast is - // safer than silent truncation. - f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("open dst: %w", err) - } - - limited := io.LimitReader(resp.Body, maxPluginZipBytes+1) - written, err := io.Copy(f, limited) - if err != nil { - _ = f.Close() - return nil, fmt.Errorf("copy body: %w", err) - } - if written > maxPluginZipBytes { - _ = f.Close() - return nil, fmt.Errorf("zip exceeds %d byte cap", maxPluginZipBytes) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - _ = f.Close() - return nil, fmt.Errorf("seek after write: %w", err) - } - return f, nil -} - -// sanitizeHTTPClientError strips the URL embedded by *url.Error. -// net/http returns errors that include the full request URL — for -// presigned OSS URLs that's OSSAccessKeyId + Signature + Expires. -// Without redaction those credentials land in the daemon log via -// PluginInstallResult.Warnings → session.go logger.Warn. -// -// Format is ` "": ` — keep the method + inner -// message, drop the URL. -func sanitizeHTTPClientError(err error) string { - if err == nil { - return "" - } - msg := err.Error() - open := strings.Index(msg, `"`) - if open < 0 { - return msg - } - close := strings.Index(msg[open+1:], `"`) - if close < 0 { - return msg - } - closeAbs := open + 1 + close - if closeAbs+2 > len(msg) { - return msg - } - return msg[:open] + "" + msg[closeAbs+1:] -} - -// verifyPluginSHA256FromFD hashes the bytes the open FD points at and -// compares against want (lowercase hex). Rewinds the FD afterwards. -func verifyPluginSHA256FromFD(fd *os.File, want string) error { - want = strings.ToLower(strings.TrimSpace(want)) - if want == "" { - return errors.New("verify: empty expected sha256") - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("verify: seek: %w", err) - } - h := sha256.New() - if _, err := io.Copy(h, fd); err != nil { - return fmt.Errorf("verify: hash: %w", err) - } - got := hex.EncodeToString(h.Sum(nil)) - if got != want { - return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) - } - return nil -} - -// extractPluginZipFromFD reads via io.NewSectionReader rather than -// re-opening the path so the byte stream stays identical to the -// verified one (TOCTOU defense). -func extractPluginZipFromFD(fd *os.File, size int64, dst string) error { - zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) - if err != nil { - return fmt.Errorf("extract: open zip: %w", err) - } - - root := detectSingleZipRoot(zr.File) - absDst, err := filepath.Abs(dst) - if err != nil { - return fmt.Errorf("extract: abs dst: %w", err) - } - - for _, f := range zr.File { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - // Skip non-regular zip entries (symlinks, devices, named - // pipes). Symlink entries flagged with Unix lrwxrwxrwx mode - // bits would otherwise be written as plain files containing - // the link target string — an exfil vector. - mode := f.Mode() - if !f.FileInfo().IsDir() && !mode.IsRegular() { - continue - } - if root != "" { - if !strings.HasPrefix(name, root) { - continue - } - name = strings.TrimPrefix(name, root) - if name == "" { - continue - } - } - - target := filepath.Join(absDst, name) - rel, err := filepath.Rel(absDst, target) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("extract: entry %q escapes target", f.Name) - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { - return fmt.Errorf("extract: mkdir %s: %w", target, err) - } - continue - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) - } - if err := writeZipEntry(f, target); err != nil { - return err - } - } - return nil -} - -// writeZipEntry streams one zip entry into target preserving the -// entry's mode bits (executables stay executable — hook scripts need -// this). 0644 default when no mode is set. -func writeZipEntry(f *zip.File, target string) error { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("extract: open entry %s: %w", f.Name, err) - } - defer rc.Close() - - mode := f.Mode().Perm() - if mode == 0 { - mode = 0o644 - } - out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { - return fmt.Errorf("extract: open target %s: %w", target, err) - } - defer out.Close() - if _, err := io.Copy(out, rc); err != nil { - return fmt.Errorf("extract: copy %s: %w", target, err) - } - return nil -} - -// detectSingleZipRoot returns the common root directory (with trailing -// slash) shared by every non-MACOSX entry, or "" when there is none. -// Hidden directories (".*") are NOT treated as wrappers because -// `.claude-plugin/` is a legitimate plugin component. -// -// `normaliseZipPath` strips trailing slashes, so a bare directory -// entry like `my-plugin/` arrives as `my-plugin` — -// indistinguishable from a top-level file. Skipping entries without an -// internal "/" lets us pick a real file path and infer the wrapper. -// Without this, `zip -r foo foo/` would short-circuit on the leading -// `foo/` directory entry and leave the manifest nested. -// (Mirrors server-side plugin_validator.detectSingleRoot — the two -// must agree.) -func detectSingleZipRoot(files []*zip.File) string { - var first string - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if !strings.Contains(name, "/") { - continue - } - first = name - break - } - if first == "" { - return "" - } - idx := strings.Index(first, "/") - if idx <= 0 { - return "" - } - root := first[:idx+1] - if strings.HasPrefix(root, ".") { - return "" - } - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - // Bare directory entries (e.g. `my-plugin`) arrive without a - // trailing slash. If the entry equals the root with the slash - // trimmed, it's the wrapping dir itself. - if name+"/" == root { - continue - } - if !strings.HasPrefix(name, root) { - return "" - } - } - return root -} - -// normaliseZipPath converts back-slashes to forward slashes (some -// Windows zip writers emit `\`) and strips trailing slashes that -// directory entries may carry. -func normaliseZipPath(name string) string { - p := strings.ReplaceAll(name, "\\", "/") - return strings.TrimSuffix(p, "/") -} - -func stringField(m map[string]any, key string) string { s, _ := m[key].(string); return s } diff --git a/apps/daemon/internal/agent/managedskills/archive_test.go b/apps/daemon/internal/agent/managedskills/archive_test.go deleted file mode 100644 index af5c30a55..000000000 --- a/apps/daemon/internal/agent/managedskills/archive_test.go +++ /dev/null @@ -1,486 +0,0 @@ -package managedskills - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -// pluginZipFile mirrors the server-side validator test helper. -type pluginZipFile struct { - Name string - Body string - Mode os.FileMode // 0 → default -} - -func buildPluginZipBytes(t *testing.T, files []pluginZipFile) []byte { - t.Helper() - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - for _, f := range files { - hdr := &zip.FileHeader{Name: f.Name, Method: zip.Deflate} - if f.Mode != 0 { - hdr.SetMode(f.Mode) - } - w, err := zw.CreateHeader(hdr) - if err != nil { - t.Fatalf("zip header %q: %v", f.Name, err) - } - if _, err := w.Write([]byte(f.Body)); err != nil { - t.Fatalf("zip write %q: %v", f.Name, err) - } - } - if err := zw.Close(); err != nil { - t.Fatalf("zip close: %v", err) - } - return buf.Bytes() -} - -// validPluginZipBytes is the baseline fixture every install test uses -// unless it explicitly mutates the entry set. -func validPluginZipBytes(t *testing.T) []byte { - return buildPluginZipBytes(t, []pluginZipFile{ - {Name: ".claude-plugin/plugin.json", Body: `{"name":"my-plugin","version":"1.0.0"}`}, - {Name: "commands/hello.md", Body: "---\nname: hello\n---\nbody"}, - }) -} - -func sha256Hex(body []byte) string { - h := sha256.Sum256(body) - return hex.EncodeToString(h[:]) -} - -// pluginServer is a deterministic stand-in for the OSS presigned GET -// endpoint. It counts hits so cache-hit tests can verify the second -// install call did NOT round-trip. -type pluginServer struct { - *httptest.Server - hits *int - body []byte - stat int -} - -func startPluginServer(t *testing.T, body []byte) *pluginServer { - t.Helper() - var hits int - ps := &pluginServer{hits: &hits, body: body, stat: http.StatusOK} - ps.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits++ - w.WriteHeader(ps.stat) - _, _ = w.Write(ps.body) - })) - t.Cleanup(ps.Close) - return ps -} - -func (s *pluginServer) Hits() int { return *s.hits } - -func discardLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -func TestInstallPlugins_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { - t.Parallel() - body := validPluginZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("installPlugins: %v", err) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v, want 1 entry", res.SkillDirs) - } - if len(res.Warnings) != 0 { - t.Fatalf("unexpected warnings: %v", res.Warnings) - } - - dir := res.SkillDirs[0] - if filepath.Base(dir) != "my-plugin" { - t.Fatalf("dir basename = %q, want my-plugin", filepath.Base(dir)) - } - if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { - t.Fatalf("manifest missing: %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "commands", "hello.md")); err != nil { - t.Fatalf("commands entry missing: %v", err) - } - stamped, err := os.ReadFile(filepath.Join(dir, ".cache-key")) - if err != nil { - t.Fatalf("read cache-key: %v", err) - } - want := "my-plugin@" + sha256Hex(body) - if string(stamped) != want { - t.Fatalf("cache-key = %q, want %q", stamped, want) - } -} - -func TestInstallPlugins_CacheHitSkipsDownload(t *testing.T) { - t.Parallel() - body := validPluginZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - desc := []skillDescriptor{ - {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - } - if _, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc); err != nil { - t.Fatalf("first install: %v", err) - } - hitsAfterFirst := srv.Hits() - if hitsAfterFirst != 1 { - t.Fatalf("first install hits = %d, want 1", hitsAfterFirst) - } - - // Second install with the same descriptor — cache-key match - // short-circuits BEFORE any HTTP call. - if _, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc); err != nil { - t.Fatalf("second install: %v", err) - } - if got := srv.Hits(); got != hitsAfterFirst { - t.Fatalf("second install made %d additional hits; cache should have prevented network", got-hitsAfterFirst) - } -} - -func TestInstallPlugins_CacheInvalidatedBySHA256Change(t *testing.T) { - t.Parallel() - body := validPluginZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - logger := discardLogger() - - first := []skillDescriptor{ - {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - } - if _, err := installSkillsForTest(context.Background(), logger, workDir, first); err != nil { - t.Fatalf("first install: %v", err) - } - - // Swap the server payload + sha. Cache key contains the sha so it - // must be invalidated. - newBody := buildPluginZipBytes(t, []pluginZipFile{ - {Name: ".claude-plugin/plugin.json", Body: `{"name":"my-plugin","version":"1.0.0"}`}, - {Name: "commands/different.md", Body: "---\nname: different\n---\nbody"}, - }) - srv.body = newBody - - second := []skillDescriptor{ - {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(newBody)}, - } - if _, err := installSkillsForTest(context.Background(), logger, workDir, second); err != nil { - t.Fatalf("second install: %v", err) - } - dir := filepath.Join(workDir, "my-plugin") - if _, err := os.Stat(filepath.Join(dir, "commands", "different.md")); err != nil { - t.Fatalf("new content not extracted: %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "commands", "hello.md")); err == nil { - t.Fatal("old content survived the re-install") - } -} - -func TestInstallPlugins_SHA256MismatchDemotesToWarning(t *testing.T) { - t.Parallel() - body := validPluginZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - // Wrong sha → no install, no hard error; rest of the prompt - // continues without this plugin. - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: strings.Repeat("0", 64)}, - }) - if err != nil { - t.Fatalf("installPlugins: %v", err) - } - if len(res.SkillDirs) != 0 { - t.Fatalf("SkillDirs = %v, want empty after sha mismatch", res.SkillDirs) - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning on sha mismatch") - } - if !strings.Contains(res.Warnings[0], "sha256 mismatch") { - t.Fatalf("warning text = %q, want sha256-mismatch hint", res.Warnings[0]) - } - // No .cache-key file must be stamped — would short-circuit future - // retries with the same bad sha. - if _, err := os.Stat(filepath.Join(workDir, "my-plugin", ".cache-key")); err == nil { - t.Fatal("cache-key stamped despite sha mismatch") - } -} - -func TestInstallPlugins_HTTPErrorDemotesToWarning(t *testing.T) { - t.Parallel() - workDir := t.TempDir() - srv := startPluginServer(t, nil) - srv.stat = http.StatusForbidden - - res, _ := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "p", Version: "1", DownloadURL: srv.URL, SHA256: strings.Repeat("a", 64)}, - }) - if len(res.SkillDirs) != 0 { - t.Fatal("expected no installed dirs on 403") - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning on 403") - } -} - -func TestInstallPlugins_StripWrappingRoot(t *testing.T) { - t.Parallel() - body := buildPluginZipBytes(t, []pluginZipFile{ - {Name: "wrapper-dir/.claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, - {Name: "wrapper-dir/commands/hi.md", Body: "---\nname: hi\n---"}, - }) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v", res.SkillDirs) - } - dir := res.SkillDirs[0] - if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { - t.Fatalf("manifest at expected path missing (wrapper not stripped?): %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "wrapper-dir")); err == nil { - t.Fatal("wrapper-dir survived the strip") - } -} - -// TestInstallPlugins_StripWrappingRootWithBareDirEntry locks in the -// production fix: real `zip -r my-plugin.zip my-plugin/` archives emit -// a bare directory entry first; naive root-inference sees no internal -// "/" and concludes "no wrapper to strip". The fix skips directory- -// only entries when picking the first candidate. -func TestInstallPlugins_StripWrappingRootWithBareDirEntry(t *testing.T) { - t.Parallel() - body := buildPluginZipBytes(t, []pluginZipFile{ - // Bare directory entry — the gotcha. - {Name: "wrapper-dir/", Body: ""}, - {Name: "wrapper-dir/.claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, - {Name: "wrapper-dir/commands/hi.md", Body: "---\nname: hi\n---"}, - }) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v", res.SkillDirs) - } - dir := res.SkillDirs[0] - if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { - t.Fatalf("manifest at expected path missing (bare dir entry confused wrapper detection?): %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "wrapper-dir")); err == nil { - t.Fatal("wrapper-dir survived the strip") - } -} - -func TestInstallPlugins_MacOSXMetadataIgnored(t *testing.T) { - t.Parallel() - body := buildPluginZipBytes(t, []pluginZipFile{ - {Name: "__MACOSX/._plugin.json", Body: "binary metadata"}, - {Name: ".claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, - }) - srv := startPluginServer(t, body) - workDir := t.TempDir() - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - dir := res.SkillDirs[0] - if _, err := os.Stat(filepath.Join(dir, "__MACOSX")); err == nil { - t.Fatal("__MACOSX dir was extracted despite filter") - } - if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { - t.Fatalf("manifest missing: %v", err) - } -} - -func TestInstallPlugins_PathTraversalRejected(t *testing.T) { - t.Parallel() - // "../../etc/passwd" entry must not write outside the target dir. - body := buildPluginZipBytes(t, []pluginZipFile{ - {Name: ".claude-plugin/plugin.json", Body: `{"name":"x","version":"1"}`}, - {Name: "../../escape", Body: "should never land outside dir"}, - }) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, _ := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if len(res.SkillDirs) != 0 { - t.Fatalf("SkillDirs = %v, want empty on path-traversal", res.SkillDirs) - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning on path-traversal") - } - if !strings.Contains(res.Warnings[0], "escapes") { - t.Fatalf("warning text = %q, want escape hint", res.Warnings[0]) - } -} - -func TestInstallPlugins_SymlinkEntrySkipped(t *testing.T) { - t.Parallel() - // archive/zip's SetMode() drops file-type bits, so to test a real - // symlink entry we set ExternalAttrs by hand — upper 16 bits are - // the Unix mode (S_IFLNK | perm), which is how attacker-crafted - // zips actually mark symlinks. - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - { - hdr := &zip.FileHeader{Name: ".claude-plugin/plugin.json", Method: zip.Deflate} - w, err := zw.CreateHeader(hdr) - if err != nil { - t.Fatalf("create manifest: %v", err) - } - if _, err := w.Write([]byte(`{"name":"x","version":"1"}`)); err != nil { - t.Fatalf("write manifest: %v", err) - } - } - { - hdr := &zip.FileHeader{Name: "commands/evil.md", Method: zip.Deflate} - // 0xA1ED = S_IFLNK (0xA000) | 0755. External attrs are - // (unix_mode << 16) per the zip spec. - hdr.ExternalAttrs = 0xA1ED << 16 - hdr.CreatorVersion = 3 << 8 // UNIX host system - w, err := zw.CreateHeader(hdr) - if err != nil { - t.Fatalf("create symlink: %v", err) - } - if _, err := w.Write([]byte("/etc/passwd")); err != nil { - t.Fatalf("write symlink target: %v", err) - } - } - if err := zw.Close(); err != nil { - t.Fatalf("zip close: %v", err) - } - body := buf.Bytes() - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v, want 1", res.SkillDirs) - } - dir := res.SkillDirs[0] - if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { - t.Fatalf("manifest missing: %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "commands", "evil.md")); err == nil { - t.Fatal("symlink entry was extracted as a regular file; symlinks must be skipped") - } -} - -func TestInstallPlugins_ConcurrentSamePluginNoTruncation(t *testing.T) { - t.Parallel() - // Per-call uuid in the temp filename makes each install's temp - // file independent; concurrent installs of the same (name, - // version) must both succeed. - body := validPluginZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - done := make(chan error, 2) - for i := 0; i < 2; i++ { - go func() { - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - done <- err - return - } - if len(res.SkillDirs) != 1 { - done <- fmt.Errorf("SkillDirs = %v", res.SkillDirs) - return - } - done <- nil - }() - } - for i := 0; i < 2; i++ { - if err := <-done; err != nil { - t.Fatalf("concurrent install failed: %v", err) - } - } -} - -func TestInstallPlugins_PartialInstall(t *testing.T) { - t.Parallel() - // Good descriptor installs; bad-sha descriptor demotes to warning. - bodyOK := buildPluginZipBytes(t, []pluginZipFile{ - {Name: ".claude-plugin/plugin.json", Body: `{"name":"good","version":"1"}`}, - }) - srvOK := startPluginServer(t, bodyOK) - srvBAD := startPluginServer(t, bodyOK) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "good", Version: "1", DownloadURL: srvOK.URL, SHA256: sha256Hex(bodyOK)}, - {Name: "bad", Version: "1", DownloadURL: srvBAD.URL, SHA256: strings.Repeat("0", 64)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v, want 1 (only the good one)", res.SkillDirs) - } - if !strings.HasSuffix(res.SkillDirs[0], "/good") { - t.Fatalf("SkillDirs[0] = %q, want trailing /good", res.SkillDirs[0]) - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning for the bad plugin") - } -} - -func TestInstallPlugins_EmptyListIsNoop(t *testing.T) { - t.Parallel() - res, err := installSkillsForTest(context.Background(), discardLogger(), t.TempDir(), nil) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 0 || len(res.Warnings) != 0 { - t.Fatalf("expected empty result; got %+v", res) - } -} - -func installSkillsForTest(ctx context.Context, logger *slog.Logger, root string, descriptors []skillDescriptor) (SkillInstallResult, error) { - raw := make([]any, 0, len(descriptors)) - for _, d := range descriptors { - raw = append(raw, map[string]any{"name": d.Name, "version": d.Version, "download_url": d.DownloadURL, "sha256": d.SHA256}) - } - return InstallManagedSkills(ctx, logger, root, raw) -} diff --git a/apps/daemon/internal/agent/managedskills/concurrency_test.go b/apps/daemon/internal/agent/managedskills/concurrency_test.go deleted file mode 100644 index dd2f40255..000000000 --- a/apps/daemon/internal/agent/managedskills/concurrency_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package managedskills - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" -) - -func installConcurrentFixture(ctx context.Context, kind, root, url string, body []byte) error { - result, err := InstallManagedSkills(ctx, discardLogger(), root, []any{map[string]any{"name": "fixture", "version": "1.0.0", "download_url": url, "sha256": sha256Hex(body)}}) - dirs, warnings := result.SkillDirs, result.Warnings - - if err != nil { - return err - } - if len(dirs) != 1 || len(warnings) != 0 { - return fmt.Errorf("dirs=%v warnings=%v", dirs, warnings) - } - content, err := os.ReadFile(filepath.Join(dirs[0], "SKILL.md")) - if err != nil { - return err - } - if string(content) != "Complete fixture contents.\n" { - return fmt.Errorf("incomplete file: %q", content) - } - return nil -} - -func TestInstallsConcurrentSameRoot(t *testing.T) { - for _, kind := range []string{"managed"} { - t.Run(kind, func(t *testing.T) { - body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) - var calls atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - _, _ = w.Write(body) - })) - defer srv.Close() - parent := t.TempDir() - alias := filepath.Join(t.TempDir(), "alias") - if err := os.Symlink(parent, alias); err != nil { - t.Fatal(err) - } - results := make(chan error, 8) - for i := range 8 { - root := filepath.Join(parent, "new", "root") - if i%2 == 1 { - root = filepath.Join(alias, "new", "root") - } - go func() { results <- installConcurrentFixture(context.Background(), kind, root, srv.URL, body) }() - } - for range 8 { - if err := <-results; err != nil { - t.Error(err) - } - } - if calls.Load() != 1 { - t.Errorf("downloads=%d, want one installation shared through the cache", calls.Load()) - } - }) - } -} - -func TestInstallWaitCancellation(t *testing.T) { - for _, kind := range []string{"managed"} { - t.Run(kind, func(t *testing.T) { - body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) - started, release := make(chan struct{}, 2), make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - started <- struct{}{} - select { - case <-release: - _, _ = w.Write(body) - case <-r.Context().Done(): - } - })) - defer srv.Close() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - root := t.TempDir() - first := make(chan error, 1) - go func() { first <- installConcurrentFixture(ctx, kind, root, srv.URL, body) }() - select { - case <-started: - case <-ctx.Done(): - t.Fatal("first download did not start") - } - waiting, cancelWait := context.WithCancel(ctx) - cancelWait() - if err := installConcurrentFixture(waiting, kind, root, srv.URL, body); err != context.Canceled { - t.Errorf("waiting install error=%v, want cancellation", err) - } - close(release) - if err := <-first; err != nil { - t.Fatal(err) - } - if err := installConcurrentFixture(ctx, kind, root, srv.URL, body); err != nil { - t.Fatalf("cancelled waiter damaged installation: %v", err) - } - if len(started) != 0 { - t.Fatal("cancelled waiter started another download") - } - }) - } -} - -func TestInstallsIndependentRootsRemainConcurrent(t *testing.T) { - body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) - started, release := make(chan struct{}, 2), make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - started <- struct{}{} - select { - case <-release: - _, _ = w.Write(body) - case <-r.Context().Done(): - } - })) - defer srv.Close() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - results := make(chan error, 2) - for _, root := range []string{t.TempDir(), t.TempDir()} { - go func() { results <- installConcurrentFixture(ctx, "managed", root, srv.URL, body) }() - } - for range 2 { - select { - case <-started: - case <-ctx.Done(): - t.Fatal("independent roots were serialized") - } - } - close(release) - for range 2 { - if err := <-results; err != nil { - t.Error(err) - } - } -} diff --git a/apps/daemon/internal/agent/managedskills/skills.go b/apps/daemon/internal/agent/managedskills/skills.go deleted file mode 100644 index 3f052ca1c..000000000 --- a/apps/daemon/internal/agent/managedskills/skills.go +++ /dev/null @@ -1,237 +0,0 @@ -package managedskills - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/google/uuid" -) - -// skillDescriptor is the daemon-side view of one server-sent skill entry -// under agent_options["skills"]. -type skillDescriptor struct { - Name string - Version string - DownloadURL string - SHA256 string -} - -// SkillInstallResult carries installed directories and warnings. -type SkillInstallResult struct { - SkillDirs []string - Warnings []string -} - -// InstallManagedSkills decodes the portable agent_options["skills"] payload, -// materializes it below root, and removes entries that are no longer active. -func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, raw any) (SkillInstallResult, error) { - if strings.TrimSpace(root) == "" { - return SkillInstallResult{}, errors.New("managed skills: root is required") - } - unlock, err := installroot.Lock(ctx, root) - if err != nil { - return SkillInstallResult{}, err - } - defer unlock() - skills, decodeWarnings := decodeSkillDescriptors(raw) - result, err := installSkillsAtRootLocked(ctx, logger, root, skills, "managed skills") - result.Warnings = append(decodeWarnings, result.Warnings...) - if err != nil { - return result, err - } - if err := pruneManagedSkills(root, result.SkillDirs); err != nil { - return result, err - } - return result, nil -} - -func installSkillsAtRootLocked( - ctx context.Context, - logger *slog.Logger, - root string, - skills []skillDescriptor, - logLabel string, -) (SkillInstallResult, error) { - if logger == nil { - logger = obslog.Bg() - } - if len(skills) == 0 { - return SkillInstallResult{}, nil - } - - result := SkillInstallResult{} - for _, s := range skills { - if err := s.validate(); err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) - logger.Warn(logLabel+": invalid descriptor", "err", err.Error()) - continue - } - - dir := filepath.Join(root, s.Name) - cacheKey := filepath.Join(dir, ".cache-key") - expectedKey := s.cacheKey() - - if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { - logger.Info(logLabel+": cache hit", - "name", s.Name, "version", s.Version, "dir", dir) - result.SkillDirs = append(result.SkillDirs, dir) - continue - } - - // Bound each archive download and extraction. - perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) - err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s, logLabel) - cancel() - if err != nil { - result.Warnings = append(result.Warnings, - fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) - logger.Warn(logLabel+": install failed", - "name", s.Name, "version", s.Version, "err", err.Error()) - continue - } - result.SkillDirs = append(result.SkillDirs, dir) - logger.Info(logLabel+": installed", - "name", s.Name, "version", s.Version, "dir", dir) - } - return result, nil -} - -func pruneManagedSkills(root string, activeDirs []string) error { - entries, err := os.ReadDir(root) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("managed skills: read root %s: %w", root, err) - } - active := make(map[string]struct{}, len(activeDirs)) - for _, dir := range activeDirs { - active[filepath.Base(dir)] = struct{}{} - } - for _, entry := range entries { - if entry.Name() == ".tmp" { - continue - } - if _, ok := active[entry.Name()]; ok { - continue - } - path := filepath.Join(root, entry.Name()) - if err := os.RemoveAll(path); err != nil { - return fmt.Errorf("managed skills: remove stale entry %s: %w", path, err) - } - } - return nil -} - -// installOneSkill downloads, verifies and extracts one skill archive. -func installOneSkill( - ctx context.Context, - logger *slog.Logger, - root, dir, cacheKey, expectedKey string, - s skillDescriptor, - logLabel string, -) error { - tmpDir := filepath.Join(root, ".tmp") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return fmt.Errorf("mkdir tmp: %w", err) - } - - zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) - defer func() { - _ = os.Remove(zipPath) - }() - - fd, err := fetchPluginZip(ctx, s.DownloadURL, zipPath) - if err != nil { - return err - } - defer fd.Close() - - if err := verifyPluginSHA256FromFD(fd, s.SHA256); err != nil { - return err - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("seek: %w", err) - } - fi, err := fd.Stat() - if err != nil { - return fmt.Errorf("stat: %w", err) - } - - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("rm old dir: %w", err) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir target: %w", err) - } - if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil { - _ = os.RemoveAll(dir) - return err - } - - if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { - logger.Warn(logLabel+": write cache key failed", - "path", cacheKey, "err", err.Error()) - } - return nil -} - -// decodeSkillDescriptors converts agent_options["skills"] into typed descriptors. -func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { - if raw == nil { - return nil, nil - } - items, ok := raw.([]any) - if !ok { - return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)} - } - out := make([]skillDescriptor, 0, len(items)) - warnings := make([]string, 0) - for i, item := range items { - obj, ok := item.(map[string]any) - if !ok { - warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i)) - continue - } - s := skillDescriptor{ - Name: stringField(obj, "name"), - Version: stringField(obj, "version"), - DownloadURL: stringField(obj, "download_url"), - SHA256: stringField(obj, "sha256"), - } - if err := s.validate(); err != nil { - warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err)) - continue - } - out = append(out, s) - } - return out, warnings -} - -func (s skillDescriptor) validate() error { - if strings.TrimSpace(s.Name) == "" { - return errors.New("name is required") - } - if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { - return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) - } - if strings.TrimSpace(s.DownloadURL) == "" { - return errors.New("download_url is required") - } - if len(s.SHA256) != 64 { - return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256)) - } - return nil -} - -func (s skillDescriptor) cacheKey() string { - return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256)) -} diff --git a/apps/daemon/internal/agent/managedskills/skills_test.go b/apps/daemon/internal/agent/managedskills/skills_test.go deleted file mode 100644 index f2977f371..000000000 --- a/apps/daemon/internal/agent/managedskills/skills_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package managedskills - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -// validSkillZipBytes is a minimal SKILL.md-rooted zip. -func validSkillZipBytes(t *testing.T) []byte { - return buildPluginZipBytes(t, []pluginZipFile{ - {Name: "SKILL.md", Body: "---\nname: code-review\ndescription: Review code\n---\nBody"}, - }) -} - -func TestInstallSkills_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { - t.Parallel() - body := validSkillZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("installSkills: %v", err) - } - if len(res.Warnings) != 0 { - t.Fatalf("unexpected warnings: %v", res.Warnings) - } - - dir := filepath.Join(workDir, "code-review") - if len(res.SkillDirs) != 1 || res.SkillDirs[0] != dir { - t.Fatalf("skill dirs = %v, want [%s]", res.SkillDirs, dir) - } - if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { - t.Fatalf("SKILL.md missing: %v", err) - } - stamped, err := os.ReadFile(filepath.Join(dir, ".cache-key")) - if err != nil { - t.Fatalf("read cache-key: %v", err) - } - want := "code-review@" + sha256Hex(body) - if string(stamped) != want { - t.Fatalf("cache-key = %q, want %q", stamped, want) - } -} - -func TestInstallSkills_CacheHitSkipsDownload(t *testing.T) { - t.Parallel() - body := validSkillZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - desc := []skillDescriptor{ - {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - } - if _, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc); err != nil { - t.Fatalf("first install: %v", err) - } - hitsAfterFirst := srv.Hits() - if hitsAfterFirst != 1 { - t.Fatalf("first install hits = %d, want 1", hitsAfterFirst) - } - second, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc) - if err != nil { - t.Fatalf("second install: %v", err) - } - if len(second.SkillDirs) != 1 { - t.Fatalf("cache hit skill dirs = %v", second.SkillDirs) - } - if got := srv.Hits(); got != hitsAfterFirst { - t.Fatalf("cache should prevent second download; got %d extra hits", got-hitsAfterFirst) - } -} - -func TestInstallManagedSkillsPrunesInactiveEntries(t *testing.T) { - body := validSkillZipBytes(t) - srv := startPluginServer(t, body) - root := t.TempDir() - stale := filepath.Join(root, "old-skill") - if err := os.MkdirAll(stale, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("old"), 0o644); err != nil { - t.Fatal(err) - } - - res, err := InstallManagedSkills(context.Background(), discardLogger(), root, []any{ - map[string]any{ - "name": "code-review", "version": "1.0.0", - "download_url": srv.URL, "sha256": sha256Hex(body), - }, - }) - if err != nil { - t.Fatalf("InstallManagedSkills: %v", err) - } - if len(res.SkillDirs) != 1 || res.SkillDirs[0] != filepath.Join(root, "code-review") { - t.Fatalf("skill dirs = %v", res.SkillDirs) - } - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("stale skill still exists: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "code-review", "SKILL.md")); err != nil { - t.Fatalf("active skill missing: %v", err) - } -} - -func TestInstallSkills_SHA256MismatchDemotesToWarning(t *testing.T) { - t.Parallel() - body := validSkillZipBytes(t) - srv := startPluginServer(t, body) - workDir := t.TempDir() - - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ - // Wrong sha256 (all-zero pattern is 64 hex chars, never matches body) - {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: "0000000000000000000000000000000000000000000000000000000000000000"}, - }) - if err != nil { - t.Fatalf("installSkills should not hard-error on sha mismatch: %v", err) - } - if len(res.Warnings) != 1 { - t.Fatalf("want 1 warning, got %d: %v", len(res.Warnings), res.Warnings) - } - if _, err := os.Stat(filepath.Join(workDir, "code-review", "SKILL.md")); err == nil { - t.Fatal("SKILL.md should not exist after sha mismatch") - } -} - -func TestInstallSkills_EmptyListIsNoop(t *testing.T) { - t.Parallel() - workDir := t.TempDir() - res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, nil) - if err != nil { - t.Fatalf("empty install: %v", err) - } - if len(res.Warnings) != 0 { - t.Fatalf("unexpected warnings: %v", res.Warnings) - } -} - -func TestDecodeSkillDescriptors_ArrayShape(t *testing.T) { - raw := []any{ - map[string]any{ - "name": "code-review", - "version": "1.0.0", - "download_url": "https://x", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000", - }, - } - got, warns := decodeSkillDescriptors(raw) - if len(got) != 1 || len(warns) != 0 { - t.Fatalf("got=%+v warns=%v", got, warns) - } -} - -func TestDecodeSkillDescriptors_NilAndWrongType(t *testing.T) { - if got, warns := decodeSkillDescriptors(nil); got != nil || warns != nil { - t.Fatalf("nil raw should be (nil, nil), got (%v, %v)", got, warns) - } - if got, warns := decodeSkillDescriptors("not-array"); got != nil || len(warns) != 1 { - t.Fatalf("string raw should warn, got got=%v warns=%v", got, warns) - } -} diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go index d2ed2e6d4..3f01ff20b 100644 --- a/apps/daemon/internal/agent/mcode/contracts.go +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -11,17 +11,10 @@ var ( _ agent.DurableSteerer = (*Session)(nil) _ agent.Steerer = (*Session)(nil) _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.PermissionResponder = (*Session)(nil) - _ agent.UserChoiceResponder = (*Session)(nil) _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) _ agent.WorkspaceReader = (*executor)(nil) _ agent.WorkspaceDirectoryLister = (*executor)(nil) _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) ) diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index bfab385d3..fa22848f1 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -14,11 +14,9 @@ import ( var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilityUnsupported, Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, Usage: proto.CapabilityUnsupported, Resume: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilitySupported, Steering: proto.CapabilityUnsupported, MessageItems: proto.CapabilityUnsupported, ToolObservations: proto.CapabilityUnsupported, @@ -48,7 +46,7 @@ func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.Su return discoverWithCheck(ctx, options, info, CheckCLIAvailable) } func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, result proto.SupportedAgentKind, check func(context.Context, string) (string, error)) *agent.Runtime { - runtime := &agent.Runtime{Info: result, Session: Factory, SessionCapabilityContext: true, ExecutorCapabilityContext: true} + runtime := &agent.Runtime{Info: result} ctx, cancel := context.WithTimeout(parent, 15*time.Second) defer cancel() @@ -58,31 +56,23 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r return runtime } result.Available, result.Version = true, version - if SupportsExecution(version) { - result.Capabilities.Steering = proto.CapabilitySupported - result.Capabilities.DurableTurns = proto.CapabilitySupported - result.Capabilities.DurableInputReceipts = proto.CapabilitySupported - result.Capabilities.ExecutionControls = proto.CapabilitySupported - result.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilitySupported - result.Capabilities.ToolObservations = proto.CapabilitySupported - result.Capabilities.SubagentControl = proto.CapabilitySupported - // Native preparation verifies the applied admission/tool profile before input. - result.Capabilities.SubagentObservations = proto.CapabilitySupported - result.Capabilities.EnvironmentNone = proto.CapabilitySupported - result.Capabilities.MCPHTTPTools = proto.CapabilitySupported - result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported - } + result.Capabilities.Steering = proto.CapabilitySupported + result.Capabilities.DurableTurns = proto.CapabilitySupported + result.Capabilities.DurableInputReceipts = proto.CapabilitySupported + result.Capabilities.ExecutionControls = proto.CapabilitySupported + result.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilitySupported + result.Capabilities.ToolObservations = proto.CapabilitySupported + result.Capabilities.SubagentControl = proto.CapabilitySupported + // Native preparation verifies the applied admission/tool profile before input. + result.Capabilities.SubagentObservations = proto.CapabilitySupported + result.Capabilities.EnvironmentNone = proto.CapabilitySupported + result.Capabilities.MCPHTTPTools = proto.CapabilitySupported + result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported runtime.Info = result workspace := discoverWorkspace(parent, options, runtime) if runtime.Info.Available { runtime.Executor = NewExecutorFactory(workspace) } - if workspace != nil { - runtime.SessionCapabilityContext = false - runtime.Info.Capabilities.WorkspaceAuthoring = proto.CapabilityUnsupported - runtime.Preparation = NewPreparationFactory(*workspace) - runtime.WorkspaceReadPreparation = true - } fmt.Fprintf(options.Stdout, "mcode preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index ab845b1c2..386c4df2d 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -2,6 +2,7 @@ package mcode import ( "context" + "errors" "io" "reflect" "testing" @@ -10,20 +11,20 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { +// An available runtime is execution-capable; a rejected native version is unavailable. +func TestMCodeExecutionFollowsAvailability(t *testing.T) { for _, tc := range []struct { - enabled, version string - qualified bool - }{{"", "0.4.12", false}, {"1", "0.3.11", false}, {"1", "0.4.12", true}} { - t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) + version string + check error + }{{SupportedVersion, nil}, {"0.3.11", errors.New("mcode: unsupported version 0.3.11")}} { + t.Run(tc.version, func(t *testing.T) { rc := agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard} - runtime := discoverWithCheck(t.Context(), rc, Declaration.Info, func(context.Context, string) (string, error) { return tc.version, nil }) - if runtime.Executor == nil || runtime.Preparation != nil || !runtime.SessionCapabilityContext || !runtime.ExecutorCapabilityContext { + runtime := discoverWithCheck(t.Context(), rc, Declaration.Info, func(context.Context, string) (string, error) { return tc.version, tc.check }) + info, available := runtime.Info, tc.check == nil + if (runtime.Executor != nil) != available || info.Capabilities.WorkspaceReadPreparation.IsSupported() { t.Fatalf("factories: %+v", runtime) } - info := runtime.Info - if !info.Available || info.Capabilities.EnvironmentNone.IsSupported() != tc.qualified || info.Capabilities.DurableInputReceipts.IsSupported() != tc.qualified || info.Capabilities.SubagentObservations.IsSupported() != tc.qualified { + if info.Available != available || info.Capabilities.EnvironmentNone.IsSupported() != available || info.Capabilities.DurableInputReceipts.IsSupported() != available || info.Capabilities.SubagentObservations.IsSupported() != available { t.Fatalf("capabilities=%+v", info.Capabilities) } if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() || info.Capabilities.FunctionTools.IsSupported() { @@ -35,7 +36,7 @@ func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { // The declaration must retain the complete baseline capability descriptor. func TestDeclaredCapabilityBaseline(t *testing.T) { - expected := map[string]bool{"Streaming": true, "Permissions": true, "Resume": true, "WorkspaceAuthoring": true} + expected := map[string]bool{"Streaming": true, "Resume": true} value := reflect.ValueOf(Declaration.Info.Capabilities) for i := 0; i < value.NumField(); i++ { name := value.Type().Field(i).Name diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go index 4438aa643..da4f768d8 100644 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -27,10 +27,6 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r if binding == nil { return nil } - if !runtime.Info.Available || !SupportsExecution(runtime.Info.Version) { - fail(fmt.Errorf("local execution requires the qualified native version")) - return nil - } root, err := paths.Root() if err != nil { fail(err) @@ -71,7 +67,6 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r caps := &runtime.Info.Capabilities caps.EnvironmentNone = proto.CapabilityUnsupported - caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported - caps.WorkspaceReadPreparation = proto.CapabilitySupported + caps.LocalEnvironment, caps.WorkspaceReadPreparation = proto.CapabilitySupported, proto.CapabilitySupported return &c } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index 1e2c745cb..5ca8565f9 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -32,11 +32,11 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { } t.Setenv("USER_SELECTED", "must-not-resolve-from-daemon") t.Setenv("MODEL_SECRET", "must-not-forward") - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close() }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) raw, err := os.ReadFile(record + ".session") if err != nil { t.Fatal(err) @@ -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) @@ -117,18 +116,18 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { if err := os.WriteFile(c.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER=prepared-mcp-cancel", 1)), 0700); err != nil { t.Fatal(err) } - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - session, err := resource.Start(ctx, "run", proto.TextInput("invoke and wait"), out) + session, err := resource.StartTurn(ctx, "run", proto.TextInput("invoke and wait"), out) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.(*prepared).Cancel(context.Background()) }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) select { case event := <-out: var call proto.ToolCallPayload @@ -191,11 +190,11 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { item.BearerToken = &value } req.LocalEnvironment.MCP = []proto.EnvironmentMCP{item} - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close() }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) raw, err := os.ReadFile(record + ".session") if err != nil { t.Fatal(err) @@ -220,7 +219,7 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { } else if len(server.Headers) != 0 { t.Fatal("anonymous MCP inherited credentials") } - dataDir := resource.(*prepared).session.opts.DataDir + dataDir := resource.(*executor).opts.DataDir for _, name := range []string{"config.yaml", "mcp.json", "workspace-profile.json"} { body, err := os.ReadFile(filepath.Join(dataDir, name)) if err != nil && !os.IsNotExist(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..a41789fd1 100644 --- a/apps/daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -7,18 +7,18 @@ import ( "net/url" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" ) func decodeComponent(value string) (string, error) { return url.PathUnescape(value) } func (s *Session) handle(frame rpcFrame) error { if len(frame.ID) > 0 { - if frame.Method == "session/request_permission" && s.active { - return s.askPermission(frame) - } - if frame.Method == "elicitation/create" && s.active { - return s.askQuestion(frame) + // Harnesses run unattended, so no client method other than the + // permission answer is offered. Pinned mcode 0.4.12 maps the ACP + // cancelled outcome to deny: the tool call is blocked and the Turn + // continues. + if frame.Method == "session/request_permission" { + return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: json.RawMessage(`{"outcome":{"outcome":"cancelled"}}`)}) } return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32601, Message: "ACP method not supported by OpenAgentCore"}}) } @@ -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 } @@ -98,35 +95,3 @@ func (s *Session) emitTool(update toolUpdate) error { } return nil } - -func (s *Session) askPermission(frame rpcFrame) error { - var request permissionRequest - if err := json.Unmarshal(frame.Params, &request); err != nil { - return fmt.Errorf("mcode: invalid permission request") - } - if request.SessionID != s.sessionID { - return fmt.Errorf("mcode: permission request belongs to another session") - } - pending := pendingPermission{RPCID: frame.ID} - for _, option := range request.Options { - switch option.Kind { - case "allow_once": - pending.Allow = option.ID - case "reject_once": - pending.Deny = option.ID - } - } - if pending.Allow == "" || pending.Deny == "" { - return fmt.Errorf("mcode: permission request has no one-time allow/deny options") - } - id := "perm_" + uuid.NewString() - s.mu.Lock() - s.permissions[id] = pending - s.mu.Unlock() - tool := request.ToolCall.Name - if tool == "" { - tool = request.ToolCall.Title - } - s.emit(proto.TypePermissionRequest, proto.PermissionRequestPayload{RequestID: id, Tool: tool, Title: request.ToolCall.Title, Payload: request.ToolCall.RawInput}) - return nil -} diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index a54bfc742..e35d927a2 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -7,11 +7,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// SupportsExecution is an operator opt-in, separate from ordinary product availability. -func SupportsExecution(version string) bool { - return os.Getenv("OAC_RUNTIME_MCODE_AGENTS_API") == "1" && version == SupportedVersion -} - func validateExecutionRequest(req proto.PromptRequestPayload) error { if !req.DisableExecutionEnvironment || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { return fmt.Errorf("mcode: unsupported execution configuration") @@ -27,15 +22,6 @@ func validateExecutionRequest(req proto.PromptRequestPayload) error { return err } } - if mode := optionString(req.AgentOptions, "mode"); mode != "" { - return fmt.Errorf("mcode: text execution uses default native permissions") - } - if req.AgentOptions["plugins"] != nil { - return fmt.Errorf("mcode: execution cannot import plugins") - } - if req.AgentOptions["skills"] != nil || req.AgentOptions["mcp_servers"] != nil || req.AgentOptions["env"] != nil { - return fmt.Errorf("mcode: execution cannot import product capabilities or environment") - } return nil } @@ -51,12 +37,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 ac7bcd53b..9f188f97f 100644 --- a/apps/daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -10,18 +10,11 @@ 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(t.Context(), r) + opts, err := prepareOptions(r) if err != nil { t.Fatal(err) } @@ -52,11 +45,10 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func(r *proto.PromptRequestPayload) { r.RequireExistingNativeSession = true }, func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "f"}} }, func(r *proto.PromptRequestPayload) { r.ExecutionControls.WebSearch = "enabled" }, - func(r *proto.PromptRequestPayload) { r.AgentOptions["env"] = map[string]any{"X": "Y"} }, } { - r := executionRequest(t) + r := testRequest(t) change(&r) - if _, err := prepareOptions(t.Context(), r); err == nil { + if _, err := prepareOptions(r); err == nil { t.Fatal("unsupported execution accepted") } } @@ -64,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 69cf28357..33ea18ed9 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -39,17 +39,17 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { 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") } var err error var opts launchOptions binary := defaultBinary() if frozen == nil { - opts, err = prepareOptions(ctx, req) + opts, err = prepareOptions(req) } else { binary = frozen.Binary - opts, err = prepareWorkspaceOptions(ctx, *frozen, req) + opts, err = prepareWorkspaceOptions(*frozen, req) } if err != nil { return nil, err @@ -63,7 +63,7 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { } func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { - bootstrap, err := launch(ctx, req, opts, binary, nil) + bootstrap, err := launch(ctx, req, opts, binary) if err != nil { return nil, err } @@ -126,14 +126,14 @@ func (e *executor) StartTurn(ctx context.Context, runID string, input proto.Mess default: } req := e.req - req.RunID, req.Input = runID, proto.TextInput(text) + req.RunID = runID s := newTurnSession(e.connection.process.Context(), req, e.opts, e.connection, out) s.executor, s.sessionID, s.nativeModel = e, e.nativeSession, e.model s.settled, s.inputDone = make(chan struct{}), make(chan struct{}) s.outputContext, s.outputCancel = context.WithCancel(context.Background()) e.active = s e.connection.setCurrent(s) - go s.runExecutorTurn() + go s.runExecutorTurn(text) go func() { select { case <-ctx.Done(): diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go index e9dd4d9a3..d2c40d507 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.AgentOptions) != 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 eeac6a0ce..59a1f5d60 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -13,10 +13,29 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { + t.Helper() + 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") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "native") + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.LocalEnvironment.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, r, record +} + 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) @@ -28,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) } @@ -191,12 +209,21 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. func TestExecutorStartFailureOwnership(t *testing.T) { t.Run("validation", func(t *testing.T) { e, record := executorFixture(t, "executor-reuse", true) - out := make(chan proto.Envelope, 1) - turn, err := e.StartTurn(t.Context(), "", proto.TextInput("invalid"), out) - if err == nil || turn != nil { - t.Fatal("invalid Start acquired output") + image := "data:image/png;base64,iVBORw0KGgo=" + for _, invalid := range []struct { + id, reason string + input proto.MessageInput + }{ + {"", "requires live context, identity", proto.TextInput("invalid")}, + {"attachment", "does not support image input", proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image", ImageURL: &image}}}}}, + } { + out := make(chan proto.Envelope, 1) + turn, err := e.StartTurn(t.Context(), invalid.id, invalid.input, out) + if err == nil || turn != nil || !strings.Contains(err.Error(), invalid.reason) { + t.Fatal("invalid Start acquired output", err) + } + close(out) } - close(out) raw, _ := os.ReadFile(record) if strings.Contains(string(raw), "session/prompt") { t.Fatal("invalid input was sent") @@ -263,7 +290,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..dc24cd17a 100644 --- a/apps/daemon/internal/agent/mcode/executor_turn.go +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -10,10 +10,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *Session) runExecutorTurn() { +func (s *Session) runExecutorTurn(prompt string) { err := s.captureSubagentBaseline() if err == nil { - err = s.executePrompt() + err = s.executePrompt(prompt) } if errors.Is(err, errTurnCancelled) { err = nil @@ -25,12 +25,8 @@ 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 { - childErr := s.settleSubagents() - s.mu.Lock() - s.subagentSettlementError = childErr - s.mu.Unlock() - if childErr != nil { + if !s.req.DisableSubagents && s.subagentHistoryReady { + if childErr := s.settleSubagents(); childErr != nil { err = childErr } } @@ -42,7 +38,7 @@ func (s *Session) runExecutorTurn() { s.mu.Lock() // ACP and native history cancellation do not prove detached tool cleanup. // Retire the owner and settle its workers before acknowledging cancellation. - if s.cancelled || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { + if s.cancelled || s.inputUncertain { reusable = false } if s.inputUncertain && err == nil { @@ -51,7 +47,6 @@ func (s *Session) runExecutorTurn() { metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode", proto.DoneMetaAgentSessionID: s.sessionID} s.outcome = proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} outcome := s.outcome - s.permissions, s.questions = map[string]pendingPermission{}, map[string]pendingQuestion{} s.mu.Unlock() if !reusable { // Unknown quiescence invalidates this owner. A successful settlement @@ -82,7 +77,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) @@ -99,9 +94,6 @@ func (s *Session) captureSubagentBaseline() error { } func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { - if s.settled == nil { - return agent.TurnSettlement{}, fmt.Errorf("mcode: Turn has no Executor owner") - } select { case <-s.settled: return s.settlement, s.settlementErr @@ -110,7 +102,7 @@ func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, er } } -func (s *Session) cancelTurn(ctx context.Context) error { +func (s *Session) Cancel(ctx context.Context) error { e := s.executor e.mu.Lock() if e.active != s { @@ -135,7 +127,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/harness_config_test.go b/apps/daemon/internal/agent/mcode/harness_config_test.go index eaf4f2c1a..a704912f9 100644 --- a/apps/daemon/internal/agent/mcode/harness_config_test.go +++ b/apps/daemon/internal/agent/mcode/harness_config_test.go @@ -1,7 +1,6 @@ package mcode import ( - "context" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "testing" ) @@ -9,11 +8,11 @@ import ( func TestNativeConfigDoesNotPretendToSupportParameters(t *testing.T) { req := testRequest(t) req.AgentOptions["harness_config"] = map[string]any{} - if _, err := prepareOptions(context.Background(), req); err != nil { + if _, err := prepareOptions(req); err != nil { t.Fatal(err) } req.AgentOptions["harness_config"] = map[string]any{"temperature": 0.5} - if _, err := prepareOptions(context.Background(), req); err != harnessconfig.ErrHarnessConfig { + if _, err := prepareOptions(req); err != harnessconfig.ErrHarnessConfig { t.Fatalf("unsupported parameter accepted: %v", err) } } diff --git a/apps/daemon/internal/agent/mcode/installation.go b/apps/daemon/internal/agent/mcode/installation.go index fe75d9258..5f6937fc1 100644 --- a/apps/daemon/internal/agent/mcode/installation.go +++ b/apps/daemon/internal/agent/mcode/installation.go @@ -12,7 +12,7 @@ import ( func Installation() agent.Installation { return agent.Installation{AgentKind: "mcode", Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, Environment: func(dir, node string) map[string]string { - return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "bridge.mjs"), "OAC_RUNTIME_MCODE_AGENTS_API": "1"} + return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "bridge.mjs")} }, Check: func(ctx context.Context, dir, node string, env []string) error { got, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, dir) diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index 1c790f731..9f613f1a9 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -14,8 +14,8 @@ import ( 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, + s := &Session{ctx: context.Background(), outputContext: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, + 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"} if err := writeMCPRegistry(s.opts.DataDir, mcpRegistryEntry("proof.server", "proof_server_2", "read.status", "read_status_2")); err != nil { diff --git a/apps/daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go index 715bf181c..f97b50508 100644 --- a/apps/daemon/internal/agent/mcode/model_provider_test.go +++ b/apps/daemon/internal/agent/mcode/model_provider_test.go @@ -16,7 +16,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { req := testRequest(t) req.AgentOptions["model_provider"].(map[string]any)["protocol"] = tc.protocol req.AgentOptions["model"] = "chosen-model" - opts, err := prepareOptions(t.Context(), req) + opts, err := prepareOptions(req) if err != nil { t.Fatal(err) } @@ -60,7 +60,7 @@ func TestOptionsRejectInvalidProvider(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := testRequest(t) req.AgentOptions["model_provider"].(map[string]any)[tc.field] = tc.value - if _, err := prepareOptions(t.Context(), req); err == nil { + if _, err := prepareOptions(req); err == nil { t.Fatal("invalid model provider accepted") } }) @@ -69,7 +69,7 @@ func TestOptionsRejectInvalidProvider(t *testing.T) { req := testRequest(t) req.AgentOptions["mcode_provider"] = req.AgentOptions["model_provider"] delete(req.AgentOptions, "model_provider") - if _, err := prepareOptions(t.Context(), req); err == nil { + if _, err := prepareOptions(req); err == nil { t.Fatal("retired native provider option accepted") } }) @@ -78,7 +78,7 @@ func TestOptionsRejectInvalidProvider(t *testing.T) { func TestOptionsAllowLoopbackProviderFixture(t *testing.T) { req := testRequest(t) req.AgentOptions["model_provider"].(map[string]any)["base_url"] = "http://127.0.0.1:4321/v1" - if _, err := prepareOptions(t.Context(), req); err != nil { + if _, err := prepareOptions(req); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/agent/mcode/native_history_test.go b/apps/daemon/internal/agent/mcode/native_history_test.go index 0106570cc..dc34007b5 100644 --- a/apps/daemon/internal/agent/mcode/native_history_test.go +++ b/apps/daemon/internal/agent/mcode/native_history_test.go @@ -7,12 +7,11 @@ import ( "strings" "testing" "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // A successful public real-model run supplies an actual foreign native ID. -// Neither that history nor a missing ID may silently become a new session. +// Neither that history nor a missing ID may prepare an Executor, so no Turn can +// run against it or silently replace it with a new session. func TestNativeMCodeHistoryIsolation(t *testing.T) { binary, options, foreign := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS"), os.Getenv("OAC_TEST_MCODE_FOREIGN_NATIVE_ID") if binary == "" || options == "" || foreign == "" { @@ -24,41 +23,15 @@ 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.AgentOptions) != nil { t.Fatal("invalid private options") } - req.AgentSessionID, req.Input = id, proto.TextInput("This input must never execute.") + req.AgentSessionID = id ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) defer cancel() - out := make(chan proto.Envelope, 64) - session, err := newSession(ctx, req, out, binary) - if err != nil { - t.Fatal(err) - } - defer func() { - cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second) - defer stop() - if err := session.Cancel(cleanup); err != nil { - t.Error(err) - } - }() - rejected := false - for event := range out { - if event.Type == proto.TypeError { - var failure proto.ErrorPayload - _ = json.Unmarshal(event.Payload, &failure) - rejected = strings.Contains(failure.Error, "session/load:") && !strings.Contains(failure.Error, "deadline exceeded") - } - if event.Type == proto.TypeDone { - var done proto.DonePayload - _ = json.Unmarshal(event.Payload, &done) - if done.Content != "" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { - t.Fatal("unowned history executed or silently replaced") - } - } - } - if !rejected { + e, err := prepareExecutor(t, ctx, req) + if e != nil || err == nil || !strings.Contains(err.Error(), "session/load:") || strings.Contains(err.Error(), "deadline exceeded") { t.Fatal("native history was not explicitly rejected") } }) diff --git a/apps/daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go index bb61fdeb3..d1fcc2cd2 100644 --- a/apps/daemon/internal/agent/mcode/native_test.go +++ b/apps/daemon/internal/agent/mcode/native_test.go @@ -1,11 +1,7 @@ package mcode import ( - "archive/zip" - "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -26,44 +22,9 @@ func TestNativeMCodeACP(t *testing.T) { t.Skip("set OAC_TEST_MCODE_INTEGRATION_BIN to run native ACP smoke test") } req := testRequest(t) + t.Setenv("OAC_RUNTIME_MCODE_BIN", binary) var mu sync.Mutex var requests []string - mcpCalls := 0 - mcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - var msg struct { - ID json.RawMessage `json:"id"` - Method string `json:"method"` - } - if json.NewDecoder(r.Body).Decode(&msg) != nil { - w.WriteHeader(400) - return - } - if len(msg.ID) == 0 { - w.WriteHeader(http.StatusAccepted) - return - } - var result any - switch msg.Method { - case "initialize": - result = map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]string{"name": "fixture", "version": "1"}} - case "tools/list": - result = map[string]any{"tools": []map[string]any{{"name": "get_fixture", "description": "Returns the fixture marker", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{}}}}} - case "tools/call": - mu.Lock() - mcpCalls++ - mu.Unlock() - result = map[string]any{"content": []map[string]string{{"type": "text", "text": "MCP-READY"}}} - default: - result = map[string]any{} - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": msg.ID, "result": result}) - })) - defer mcp.Close() model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any if json.NewDecoder(r.Body).Decode(&body) != nil { @@ -74,50 +35,19 @@ func TestNativeMCodeACP(t *testing.T) { mu.Lock() requests = append(requests, string(raw)) mu.Unlock() - tool := "" - if !strings.Contains(string(raw), "MCP-READY") && strings.Contains(string(raw), "QA-CALL-MCP") { - tools, _ := body["tools"].([]any) - for _, entry := range tools { - value, _ := entry.(map[string]any) - name, _ := value["name"].(string) - if strings.Contains(name, "get_fixture") { - tool = name - break - } - } - } - writeNativeResponse(w, tool) + writeNativeResponse(w) })) defer model.Close() - var archive bytes.Buffer - zw := zip.NewWriter(&archive) - f, _ := zw.Create("SKILL.md") - _, _ = f.Write([]byte("---\nname: qa-mcode-skill\ndescription: Test skill marker SKILL-MCODE-451\n---\nReturn SKILL-MCODE-451.\n")) - _ = zw.Close() - digest := sha256.Sum256(archive.Bytes()) - skill := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(archive.Bytes()) })) - defer skill.Close() req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": model.URL, "api_key": "fixture-only", "context_window": 64000, "max_output_tokens": 4096} - req.AgentOptions["skills"] = []any{map[string]any{"name": "qa-mcode-skill", "version": "1", "download_url": skill.URL, "sha256": hex.EncodeToString(digest[:])}} - req.AgentOptions["mcp_servers"] = map[string]any{"qa": map[string]any{"type": "http", "url": mcp.URL}} - req.AgentOptions["system_prompt"] = "SP-MCODE-672: use the available tools when requested." - req.Input = proto.TextInput("QA-CALL-MCP: call get_fixture, then reply OAC-MCODE-OK.") + req.AgentOptions["system_prompt"] = "SP-MCODE-672: reply as instructed." + req.Input = proto.TextInput("Reply OAC-MCODE-OK.") run := func() proto.DonePayload { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) t.Cleanup(cancel) out := make(chan proto.Envelope, 64) - session, err := newSession(ctx, req, out, binary) - if err != nil { + if _, err := startTurn(t, ctx, req, out); err != nil { t.Fatal(err) } - t.Cleanup(func() { - _ = session.Cancel(context.Background()) - select { - case <-session.exited: - case <-time.After(5 * time.Second): - t.Error("native CLI did not stop") - } - }) var done proto.DonePayload for event := range out { if event.Type == proto.TypeError { @@ -138,19 +68,14 @@ func TestNativeMCodeACP(t *testing.T) { done := run() mu.Lock() firstRequests := strings.Join(requests, "\n") - calls := mcpCalls requests = nil mu.Unlock() - if !strings.Contains(firstRequests, "SP-MCODE-672") || !strings.Contains(firstRequests, "qa-mcode-skill") { - t.Fatalf("native context missing: prompt=%t skill=%t", strings.Contains(firstRequests, "SP-MCODE-672"), strings.Contains(firstRequests, "qa-mcode-skill")) - } - if calls == 0 { - t.Fatal("native MCP was not called") + if !strings.Contains(firstRequests, "SP-MCODE-672") { + t.Fatal("native instructions missing") } req.RunID = "run-2" req.AgentSessionID = done.Metadata[proto.DoneMetaAgentSessionID].(string) req.AgentOptions["system_prompt"] = "SP-MCODE-NEW: reply concisely." - req.AgentOptions["skills"] = []any{} req.AgentOptions["model"] = "fixture-new" req.Input = proto.TextInput("Now reply OAC-MCODE-OK.") run() @@ -163,26 +88,19 @@ func TestNativeMCodeACP(t *testing.T) { if !strings.Contains(resumed, `"model":"fixture-new"`) { t.Fatal("resume did not use the updated model") } - t.Logf("native new/resume, model, instructions, Skill discovery and MCP verified (%d tool calls)", calls) + t.Log("native new/resume, model and instructions verified") } -func writeNativeResponse(w http.ResponseWriter, tool string) { +func writeNativeResponse(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") send := func(kind string, payload any) { data, _ := json.Marshal(payload) _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, data) } send("message_start", map[string]any{"type": "message_start", "message": map[string]any{"id": "msg_qa", "type": "message", "role": "assistant", "model": "fixture", "content": []any{}, "stop_reason": nil, "stop_sequence": nil, "usage": map[string]int{"input_tokens": 20, "output_tokens": 0}}}) - stop := "end_turn" - if tool != "" { - send("content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "tool_use", "id": "call_fixture", "name": tool, "input": map[string]any{}}}) - send("content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]string{"type": "input_json_delta", "partial_json": "{}"}}) - stop = "tool_use" - } else { - send("content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]string{"type": "text", "text": ""}}) - send("content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]string{"type": "text_delta", "text": "OAC-MCODE-OK"}}) - } + send("content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]string{"type": "text", "text": ""}}) + send("content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]string{"type": "text_delta", "text": "OAC-MCODE-OK"}}) send("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) - send("message_delta", map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}, "usage": map[string]int{"output_tokens": 8}}) + send("message_delta", map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "usage": map[string]int{"output_tokens": 8}}) send("message_stop", map[string]string{"type": "message_stop"}) } diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 86305f17d..6fc7e3492 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -1,20 +1,15 @@ package mcode import ( - "context" "encoding/json" "fmt" "os" "path/filepath" - "sort" "strconv" - "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" - "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) type launchOptions struct { @@ -23,28 +18,19 @@ type launchOptions struct { MCP []map[string]any } -func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { - return prepareOptionsWithSkills(ctx, req, true) -} - -func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayload, managedSkills bool) (launchOptions, error) { +func prepareOptions(req proto.PromptRequestPayload) (launchOptions, error) { var result launchOptions if _, err := harnessconfiguration.Configuration().PrepareHarnessConfig(req.AgentOptions); err != nil { return result, err } - if req.StrictResume { - if err := validateExecutionRequest(req); err != nil { - return result, err - } - } - if req.Input.HasImages() { - return result, fmt.Errorf("mcode: ACP does not support attachments") + if err := validateExecutionRequest(req); err != nil { + return result, err } - root, err := agent.ManagedSkillsRoot("mcode", req.AgentStateKey, req.ConversationID, req.RunID) + dataDir, err := agent.StateDir("mcode", req.AgentStateKey) if err != nil { return result, err } - result.DataDir = filepath.Dir(root) + result.DataDir = dataDir result.Dir = filepath.Join(result.DataDir, "workspace") if err := os.MkdirAll(result.DataDir, 0o700); err != nil { return result, err @@ -52,20 +38,8 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa if err := os.MkdirAll(result.Dir, 0o700); err != nil { return result, err } - if managedSkills { - installed, err := managedskills.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) - if err != nil { - return result, err - } - if len(installed.Warnings) > 0 { - return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") - } - } opts := req.AgentOptions prompt := optionString(opts, "system_prompt") - if override := optionString(opts, "override_system_prompt"); override != "" { - prompt = override - } if len(prompt) > 32*1024 { return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") } @@ -82,24 +56,15 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa return result, err } config["custom_provider"] = map[string]any{"oac": provider} - 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}, - }} - } - } - mode := optionString(opts, "mode") - if mode == "" { - mode = "auto" - } - if mode != "auto" && mode != "default" && mode != "bypassPermissions" { - return result, fmt.Errorf("mcode: unsupported permission mode") - } - config["permissionMode"] = mode + 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" data, err := json.Marshal(config) if err != nil { return result, err @@ -107,122 +72,22 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa if err := os.WriteFile(filepath.Join(result.DataDir, "config.yaml"), data, 0o600); err != nil { return result, err } - result.Env = append([]string{}, os.Environ()...) - if req.StrictResume { - result.Env = append(executionEnvironment(), "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") - if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { - return result, err - } - if !req.DisableSubagents { - result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) - } else { - result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") - } + result.Env = append(executionEnvironment(), "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") + if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { + return result, err } - if raw := opts["env"]; raw != nil && !req.StrictResume { - env, ok := raw.(map[string]any) - if !ok { - return result, fmt.Errorf("mcode: env must be an object") - } - for key, rawValue := range env { - value, ok := rawValue.(string) - if !ok || key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) { - return result, fmt.Errorf("mcode: invalid environment entry") - } - result.Env = append(result.Env, key+"="+value) - } + if !req.DisableSubagents { + result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) + } else { + result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") } // The adapter owns the native state location, including after cold resume. - result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir) - if req.StrictResume { - result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) - } - result.MCP, err = mcpServers(opts["mcp_servers"]) - return result, err + result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) + result.MCP = []map[string]any{} + return result, nil } func optionString(options map[string]any, key string) string { value, _ := options[key].(string) return value } - -func mcpServers(raw any) ([]map[string]any, error) { - result := []map[string]any{} - if raw == nil { - return result, nil - } - servers, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("mcode: mcp_servers must be an object") - } - names := make([]string, 0, len(servers)) - for name := range servers { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - entry, ok := servers[name].(map[string]any) - if !ok { - return nil, fmt.Errorf("mcode: invalid MCP server %q", name) - } - server := map[string]any{"name": name} - if url := optionString(entry, "url"); url != "" { - kind := optionString(entry, "type") - if kind == "" { - kind = "http" - } - if kind != "http" && kind != "sse" { - return nil, fmt.Errorf("mcode: unsupported MCP transport %q", kind) - } - server["type"] = kind - server["url"] = url - headers, err := namedValues(entry["headers"]) - if err != nil { - return nil, err - } - server["headers"] = headers - } else { - command := optionString(entry, "command") - if command == "" { - return nil, fmt.Errorf("mcode: MCP server %q needs command or URL", name) - } - server["command"] = command - args := entry["args"] - if args == nil { - args = []string{} - } - server["args"] = args - env, err := namedValues(entry["env"]) - if err != nil { - return nil, err - } - server["env"] = env - } - result = append(result, server) - } - return result, nil -} - -func namedValues(raw any) ([]map[string]string, error) { - result := []map[string]string{} - if raw == nil { - return result, nil - } - values, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("mcode: MCP headers/env must be an object") - } - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - value, ok := values[key].(string) - if !ok { - return nil, fmt.Errorf("mcode: MCP headers/env values must be strings") - } - result = append(result, map[string]string{"name": key, "value": value}) - } - return result, nil -} diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index dde7201c9..70d736c86 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -1,7 +1,6 @@ package mcode import ( - "context" "encoding/json" "os" "path/filepath" @@ -12,22 +11,27 @@ import ( ) func TestOptionsRefreshManagedState(t *testing.T) { + t.Setenv("MINIMAX_DATA_DIR", "/wrong") req := testRequest(t) - req.AgentOptions["env"] = map[string]any{"MINIMAX_DATA_DIR": "/wrong", "FIXTURE": "yes"} - opts, err := prepareOptions(context.Background(), req) + opts, err := prepareOptions(req) if err != nil { t.Fatal(err) } 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("state override did not win") } req.AgentOptions["system_prompt"] = "" - req.AgentOptions["mode"] = "default" req.AgentSessionID = "native-1" - refreshed, err := prepareOptions(context.Background(), req) + refreshed, err := prepareOptions(req) if err != nil { t.Fatal(err) } @@ -46,8 +50,8 @@ func TestOptionsRefreshManagedState(t *testing.T) { if json.Unmarshal(data, &cfg) != nil { t.Fatal("invalid config") } - if cfg["permissionMode"] != "default" { - t.Fatal("requested native permission mode was not refreshed") + if cfg["permissionMode"] != "auto" { + t.Fatal("native permission mode changed") } if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { t.Fatal("external discovery enabled") @@ -64,55 +68,16 @@ func TestOptionsRejectDroppedContext(t *testing.T) { edit func(*proto.PromptRequestPayload) }{ {"oversized instructions", func(r *proto.PromptRequestPayload) { r.AgentOptions["system_prompt"] = strings.Repeat("x", 32*1024+1) }}, - {"attachment", func(r *proto.PromptRequestPayload) { - r.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} - }}, {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }}, - {"invalid permission mode", func(r *proto.PromptRequestPayload) { r.AgentOptions["mode"] = "plan" }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := testRequest(t) tt.edit(&req) - if _, err := prepareOptions(context.Background(), req); err == nil { + if _, err := prepareOptions(req); err == nil { t.Fatal("expected validation failure") } }) } } - -func TestMCPTransportConversion(t *testing.T) { - servers, err := mcpServers(map[string]any{ - "local": map[string]any{"command": "fixture", "args": []any{"--stdio"}, "env": map[string]any{"TOKEN": "test"}}, - "remote": map[string]any{"type": "http", "url": "https://mcp.example.test", "headers": map[string]any{"Authorization": "Bearer fixture"}}, - }) - if err != nil { - t.Fatal(err) - } - if len(servers) != 2 || servers[0]["command"] != "fixture" || servers[1]["type"] != "http" { - t.Fatalf("servers=%v", servers) - } - if servers[0]["env"].([]map[string]string)[0]["name"] != "TOKEN" || servers[1]["headers"].([]map[string]string)[0]["value"] != "Bearer fixture" { - t.Fatal("MCP credentials lost") - } -} - -func TestQuestionContentPreservesTypesAndValidates(t *testing.T) { - pending := pendingQuestion{Properties: map[string]formProperty{"text": {Type: "string"}, "choice": {Type: "string", Options: []formOption{{Value: "eu", Title: "Europe"}}}}, Required: []string{"choice"}} - if _, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{}); err == nil { - t.Fatal("required answer accepted empty") - } - decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "text", Answers: []string{"custom"}}, {QuestionID: "choice", Answers: []string{"Europe"}}}} - content, err := questionContent(pending, decision) - if err != nil { - t.Fatal(err) - } - if content["choice"] != "eu" || content["text"] != "custom" { - t.Fatalf("answers=%v", content) - } - decision.QuestionAnswers[1].Answers = []string{"unoffered"} - if _, err := questionContent(pending, decision); err == nil { - t.Fatal("unoffered choice accepted") - } -} diff --git a/apps/daemon/internal/agent/mcode/preparation.go b/apps/daemon/internal/agent/mcode/preparation.go deleted file mode 100644 index 1366cb5ba..000000000 --- a/apps/daemon/internal/agent/mcode/preparation.go +++ /dev/null @@ -1,87 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Prepared retains its single admission API over the same native Executor. -// Runtime's Session lifecycle uses Executor directly. -type prepared struct { - mu sync.Mutex - executor *executor - session *Session - started, closed bool -} - -func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { - factory := NewExecutorFactory(&config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - value, err := factory(ctx, req) - if err != nil { - if value != nil { - return &prepared{executor: value.(*executor)}, err - } - return nil, err - } - e := value.(*executor) - return &prepared{executor: e, session: newTurnSession(ctx, req, e.opts, e.connection, nil)}, nil - } -} - -func (p *prepared) Start(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed || p.started { - return nil, fmt.Errorf("mcode: preparation is no longer available") - } - turn, err := p.executor.StartTurn(ctx, runID, input, out) - if turn != nil { - p.started, p.session = true, turn.(*Session) - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - started := p.started - if !started { - p.closed = true - } - p.mu.Unlock() - if started { - return nil - } - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - current, started := p.session, p.started - p.mu.Unlock() - if started { - if err := current.Cancel(ctx); err != nil { - // The single-use Prepared owns disposal as well as cancellation. - if closeErr := p.executor.Close(ctx); closeErr != nil { - return closeErr - } - return nil - } - } - return p.executor.Close(ctx) -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - s := p.session - p.mu.Unlock() - if s == nil { - return proto.DonePayload{} - } - return s.CancellationOutcome() -} diff --git a/apps/daemon/internal/agent/mcode/preparation_test.go b/apps/daemon/internal/agent/mcode/preparation_test.go deleted file mode 100644 index 93b095ee4..000000000 --- a/apps/daemon/internal/agent/mcode/preparation_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { - t.Helper() - r := executionRequest(t) - r.RunID, r.Input, r.ConversationID = "", nil, "" - r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} - record := filepath.Join(t.TempDir(), "calls") - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "native") - quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.LocalEnvironment.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, r, record -} - -func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") { - t.Fatalf("preparation consumed input: %q %v", raw, err) - } - if p.session.opts.Dir != r.LocalEnvironment.WorkspaceRoot || p.session.opts.DataDir == r.LocalEnvironment.WorkspaceRoot { - t.Fatal("native cwd must use the workspace without moving Session state") - } - out := make(chan proto.Envelope) - var wg sync.WaitGroup - winners := make(chan bool, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - _, err := p.Start(ctx, "run", proto.TextInput("input"), out) - winners <- err == nil - }() - } - wg.Wait() - close(winners) - n := 0 - for winner := range winners { - if winner { - n++ - } - } - if n != 1 { - t.Fatalf("owners=%d", n) - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - deltas, done := 0, 0 - for e := range out { - if e.Type == proto.TypeError { - t.Fatalf("execution: %s", e.Payload) - } - if e.Type == proto.TypeDelta { - deltas++ - } - if e.Type == proto.TypeDone { - done++ - select { - case <-p.session.exited: - t.Fatal("successful Turn disposed the reusable native owner") - default: - } - var d proto.DonePayload - _ = json.Unmarshal(e.Payload, &d) - if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("native identity lost") - } - } - } - if deltas != 100 || done != 1 { - t.Fatalf("deltas=%d done=%d", deltas, done) - } - raw, _ = os.ReadFile(record) - if strings.Count(string(raw), "session/prompt") != 1 { - t.Fatalf("inputs=%s", raw) - } -} - -func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { - c, r, _ := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } - if _, err = resource.Start(ctx, "run", proto.TextInput("input"), make(chan proto.Envelope)); err == nil { - t.Fatal("released preparation started") - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } -} - -func TestPreparedSubagentsReleaseUnusedOwner(t *testing.T) { - for _, method := range []string{"close", "cancel"} { - t.Run(method, func(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - // The fixture only implements preparation. Enable execution cancellation's - // child branch after initialization to verify unused owners never enter it. - p.session.req.DisableSubagents = false - ended := make(chan error, 1) - go func() { - if method == "close" { - ended <- p.Close() - } else { - ended <- p.Cancel(ctx) - } - }() - select { - case err := <-ended: - if err != nil { - t.Fatal(err) - } - case <-ctx.Done(): - p.session.process.Cancel() - t.Fatal("unused owner did not close") - } - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") || strings.Contains(string(raw), "delegation/stop") { - t.Fatalf("unused preparation executed work: %q %v", raw, err) - } - }) - } -} diff --git a/apps/daemon/internal/agent/mcode/protocol.go b/apps/daemon/internal/agent/mcode/protocol.go index 95c9f629a..116d8c024 100644 --- a/apps/daemon/internal/agent/mcode/protocol.go +++ b/apps/daemon/internal/agent/mcode/protocol.go @@ -50,18 +50,3 @@ type sessionUpdate struct { toolUpdate } `json:"update"` } - -type permissionRequest struct { - SessionID string `json:"sessionId"` - ToolCall toolUpdate `json:"toolCall"` - Options []struct { - ID string `json:"optionId"` - Kind string `json:"kind"` - } `json:"options"` -} - -type pendingPermission struct { - RPCID json.RawMessage - Allow string - Deny string -} diff --git a/apps/daemon/internal/agent/mcode/questions.go b/apps/daemon/internal/agent/mcode/questions.go deleted file mode 100644 index b61721d97..000000000 --- a/apps/daemon/internal/agent/mcode/questions.go +++ /dev/null @@ -1,217 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -type formOption struct { - Value string `json:"const"` - Title string `json:"title"` - Description string `json:"description"` -} - -type formProperty struct { - Type string `json:"type"` - Title string `json:"title"` - Description string `json:"description"` - Options []formOption `json:"oneOf"` - Items struct { - Options []formOption `json:"anyOf"` - } `json:"items"` -} - -type pendingQuestion struct { - RPCID json.RawMessage - Properties map[string]formProperty - Required []string - OtherFields map[string]string -} - -func (s *Session) askQuestion(frame rpcFrame) error { - var request struct { - SessionID string `json:"sessionId"` - Mode string `json:"mode"` - Schema struct { - Type string `json:"type"` - Properties map[string]formProperty `json:"properties"` - Required []string `json:"required"` - } `json:"requestedSchema"` - } - if err := json.Unmarshal(frame.Params, &request); err != nil { - return fmt.Errorf("mcode: invalid input request") - } - if request.SessionID != s.sessionID { - return fmt.Errorf("mcode: input request belongs to another session") - } - if request.Mode != "form" || request.Schema.Type != "object" || len(request.Schema.Properties) == 0 { - return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "OpenAgentCore requires a nonempty input form"}}) - } - otherFields := questionnaireOtherFields(request.Schema.Properties) - for _, key := range otherFields { - delete(request.Schema.Properties, key) - } - keys := make([]string, 0, len(request.Schema.Properties)) - for key := range request.Schema.Properties { - keys = append(keys, key) - } - sort.Strings(keys) - questions := make([]proto.PromptForUserChoiceQuestion, 0, len(keys)) - for _, key := range keys { - property := request.Schema.Properties[key] - if property.Type != "string" && property.Type != "array" { - return s.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "OpenAgentCore supports text and choice input fields"}}) - } - question := proto.PromptForUserChoiceQuestion{ID: key, Question: property.Title, MultiSelect: property.Type == "array", Options: []proto.PromptForUserChoiceOption{}} - if question.Question == "" { - question.Question = key - } - if property.Description != "" { - question.Question += "\n" + property.Description - } - options := property.Options - if question.MultiSelect { - options = property.Items.Options - } - labels := map[string]bool{} - for _, option := range options { - label := option.Title - if label == "" { - label = option.Value - } - if labels[label] { - return fmt.Errorf("mcode: input choices have ambiguous labels") - } - labels[label] = true - question.Options = append(question.Options, proto.PromptForUserChoiceOption{Label: label, Description: option.Description}) - } - question.IsOther = len(options) == 0 || otherFields[key] != "" - questions = append(questions, question) - } - id := "ask_" + uuid.NewString() - s.mu.Lock() - s.questions[id] = pendingQuestion{RPCID: frame.ID, Properties: request.Schema.Properties, Required: request.Schema.Required, OtherFields: otherFields} - s.mu.Unlock() - s.emit(proto.TypePromptForUserChoice, proto.PromptForUserChoicePayload{AskID: id, Questions: questions}) - return nil -} - -// Native questionnaires encode Other as a companion field, not a second question. -func questionnaireOtherFields(properties map[string]formProperty) map[string]string { - fields := map[string]string{} - for key, property := range properties { - if len(property.Options) == 0 && len(property.Items.Options) == 0 { - continue - } - for candidate, other := range properties { - if strings.HasPrefix(candidate, key+"__other") && strings.Trim(strings.TrimPrefix(candidate, key+"__other"), "_") == "" && other.Type == "string" && len(other.Options) == 0 && other.Title == property.Title+" — Other" { - fields[key] = candidate - break - } - } - } - return fields -} - -func (s *Session) SubmitPromptForUserChoice(_ context.Context, id string, decision proto.PromptForUserChoiceDecisionPayload) error { - s.mu.Lock() - defer s.mu.Unlock() - pending, ok := s.questions[id] - if !ok { - return agent.ErrUnknownAsk - } - if err := decision.Validate(); err != nil { - return err - } - response := map[string]any{"action": "cancel"} - if !decision.Cancelled { - content, err := questionContent(pending, decision) - if err != nil { - return err - } - response = map[string]any{"action": "accept", "content": content} - } - raw, _ := json.Marshal(response) - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: pending.RPCID, Result: raw}); err != nil { - return err - } - delete(s.questions, id) - return nil -} - -func questionContent(pending pendingQuestion, decision proto.PromptForUserChoiceDecisionPayload) (map[string]any, error) { - ids := make([]string, 0, len(pending.Properties)) - for id := range pending.Properties { - ids = append(ids, id) - } - if _, err := decision.AnswersFor(ids); err != nil { - return nil, err - } - content := map[string]any{} - for _, answer := range decision.QuestionAnswers { - property, ok := pending.Properties[answer.QuestionID] - if !ok { - return nil, fmt.Errorf("mcode: unknown input field") - } - values := append([]string{}, answer.Answers...) - if len(values) == 0 { - continue - } - options := property.Options - if property.Type == "array" { - options = property.Items.Options - } else if len(values) != 1 { - return nil, fmt.Errorf("mcode: input field requires a single answer") - } - selected := make([]string, 0, len(values)) - for _, value := range values { - if len(options) == 0 { - selected = append(selected, value) - continue - } - found := false - for _, option := range options { - label := option.Title - if label == "" { - label = option.Value - } - if value == label { - selected = append(selected, option.Value) - found = true - break - } - } - if !found { - other := pending.OtherFields[answer.QuestionID] - if other == "" { - return nil, fmt.Errorf("mcode: answer is not an offered choice") - } - if _, exists := content[other]; exists { - return nil, fmt.Errorf("mcode: input field requires a single custom answer") - } - content[other] = value - } - } - if len(selected) == 0 { - continue - } - if property.Type == "array" { - content[answer.QuestionID] = selected - } else { - content[answer.QuestionID] = selected[0] - } - } - for _, key := range pending.Required { - if _, ok := content[key]; !ok { - return nil, fmt.Errorf("mcode: required input is missing") - } - } - return content, nil -} diff --git a/apps/daemon/internal/agent/mcode/questions_test.go b/apps/daemon/internal/agent/mcode/questions_test.go deleted file mode 100644 index c46562977..000000000 --- a/apps/daemon/internal/agent/mcode/questions_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package mcode - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestQuestionnaireOtherUsesOneQuestion(t *testing.T) { - for _, multiple := range []bool{false, true} { - property := formProperty{Type: "string", Title: "Region?", Options: []formOption{{Value: "eu", Title: "Europe"}}} - if multiple { - property.Type = "array" - property.Items.Options = property.Options - property.Options = nil - } - properties := map[string]formProperty{"region": property, "region__other": {Type: "string", Title: "Region? — Other"}} - params, _ := json.Marshal(map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "properties": properties}}) - out := make(chan proto.Envelope, 1) - session := &Session{ctx: t.Context(), sessionID: "native-1", out: out, questions: map[string]pendingQuestion{}} - if err := session.askQuestion(rpcFrame{ID: json.RawMessage(`1`), Params: params}); err != nil { - t.Fatal(err) - } - var request proto.PromptForUserChoicePayload - _ = json.Unmarshal((<-out).Payload, &request) - if len(request.Questions) != 1 || !request.Questions[0].IsOther || request.Questions[0].MultiSelect != multiple { - t.Fatalf("unexpected questions: %#v", request.Questions) - } - pending := session.questions[request.AskID] - for _, custom := range []bool{false, true} { - answer := "Europe" - want := map[string]any{"region": "eu"} - if multiple { - want["region"] = []string{"eu"} - } - if custom { - answer = "Asia" - want = map[string]any{"region__other": "Asia"} - } - got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{answer}}}}) - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("multiple=%t custom=%t: got=%v err=%v", multiple, custom, got, err) - } - } - if multiple { - got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{"Europe", "Asia"}}}}) - want := map[string]any{"region": []string{"eu"}, "region__other": "Asia"} - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("combined choice and custom: got=%v err=%v", got, err) - } - } - } -} diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index d93877cfa..528d0338b 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -3,7 +3,6 @@ package mcode import ( "context" "encoding/json" - "errors" "fmt" "net/url" "slices" @@ -21,141 +20,52 @@ type Session struct { req proto.PromptRequestPayload opts launchOptions *connection - executor *executor - settlement agent.TurnSettlement - settlementErr error - settled chan struct{} - inputDone chan struct{} - outputCancel context.CancelFunc - operations sync.WaitGroup - closing bool - cancelled bool - inputUncertain bool - out chan<- proto.Envelope - frames chan rpcFrame - finished chan struct{} - mu sync.Mutex - sessionID string - nativeModel string - outputContext context.Context - outcome proto.DonePayload - permissions map[string]pendingPermission - questions map[string]pendingQuestion - steeringReady bool - steeringTurn string - sequence uint64 - active bool - content strings.Builder - tools map[string]toolUpdate - completedTools map[string]bool - previousNativeTurns map[string]bool - subagentSettlementError error - rootCompletedAtMS *int64 - subagentHistoryReady bool + executor *executor + settlement agent.TurnSettlement + settlementErr error + settled chan struct{} + inputDone chan struct{} + outputCancel context.CancelFunc + operations sync.WaitGroup + closing bool + cancelled bool + inputUncertain bool + out chan<- proto.Envelope + frames chan rpcFrame + finished chan struct{} + mu sync.Mutex + sessionID string + nativeModel string + outputContext context.Context + outcome proto.DonePayload + steeringReady bool + steeringTurn string + sequence uint64 + active bool + content strings.Builder + tools map[string]toolUpdate + completedTools map[string]bool + previousNativeTurns map[string]bool + rootCompletedAtMS *int64 + subagentHistoryReady bool } var _ agent.Session = (*Session)(nil) -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultBinary()) -} - -func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, binary string) (*Session, error) { - if ctx == nil { - ctx = context.Background() - } - if out == nil { - return nil, fmt.Errorf("mcode: output channel is required") - } - opts, err := prepareOptions(ctx, req) - if err != nil { - return nil, err - } - s, err := launch(ctx, req, opts, binary, out) - if err != nil { - return nil, err - } - go s.run() - return s, nil -} - -func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string, out chan<- proto.Envelope) (*Session, error) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) +func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*Session, error) { + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true}) if err != nil { return nil, err } c := &connection{process: process, exited: make(chan struct{}), responses: map[string]chan rpcFrame{}} - s := newTurnSession(ctx, req, opts, c, out) + s := newTurnSession(ctx, req, opts, c, nil) c.current = s go c.read() return s, nil } func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { - return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} -} - -func (s *Session) run() { - defer func() { - if s.out != nil { - close(s.out) - } - }() - defer close(s.finished) - err := s.prepareNative() - if err == nil { - if s.req.StrictResume && !s.req.DisableSubagents { - var snapshot nativeSubagentSnapshot - snapshot, err = s.readSubagents(s.ctx) - s.subagentHistoryReady = err == nil - s.previousNativeTurns = map[string]bool{} - for _, session := range snapshot.Sessions { - if session.ID == s.sessionID { - for _, turn := range session.Turns { - s.previousNativeTurns[turn.ID] = true - } - } - } - } - } - if err == nil { - err = s.executePrompt() - } - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { - observationErr := s.settleSubagents() - s.mu.Lock() - s.subagentSettlementError = observationErr - s.mu.Unlock() - if observationErr != nil { - err = observationErr - } - } - if err != nil { - s.process.Cancel() - <-s.exited - } - s.finishEnvironmentMCP() - if s.out == nil { - return - } - s.mu.Lock() - s.steeringReady = false - s.mu.Unlock() - if err != nil { - s.process.Cancel() - s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) - } - s.mu.Lock() - sessionID := s.sessionID - s.permissions = map[string]pendingPermission{} - s.questions = map[string]pendingQuestion{} - s.mu.Unlock() - metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} - if sessionID != "" { - metadata[proto.DoneMetaAgentSessionID] = sessionID - } - // ACP context usage is not per-turn token usage; do not record it as spend. - s.emit(proto.TypeDone, proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS}) + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} } func (s *Session) prepareNative() error { @@ -168,13 +78,13 @@ func (s *Session) prepareNative() error { } `json:"oac/subagents"` } `json:"_meta"` } - if err := s.call("initialize", map[string]any{"protocolVersion": 1, "clientInfo": map[string]string{"name": "oac", "version": "1"}, "clientCapabilities": map[string]any{"elicitation": map[string]any{"form": map[string]any{}}}}, &initialized, false); err != nil { + if err := s.call("initialize", map[string]any{"protocolVersion": 1, "clientInfo": map[string]string{"name": "oac", "version": "1"}}, &initialized, false); err != nil { return err } 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} @@ -212,16 +122,12 @@ func (s *Session) prepareNative() error { return nil } -func (s *Session) executePrompt() error { - prompt, err := s.req.Input.TextOnly() - if err != nil { - return err - } +func (s *Session) executePrompt(prompt string) error { s.active = true 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 @@ -275,7 +181,7 @@ func (s *Session) call(method string, params any, result any, prompt bool) error if err != nil { return err } - if prompt && s.executor != nil { + if prompt { // Serialize the admission check with the wire, but release the owner // mutex before a pipe write so cancellation and Close can stop it. s.connection.writeMu.Lock() @@ -337,10 +243,6 @@ func (s *Session) call(method string, params any, result any, prompt bool) error } func (s *Session) emit(kind string, payload any) { - ctx := s.ctx - if s.outputContext != nil { - ctx = s.outputContext - } env, err := proto.NewEnvelope(kind, s.req.RunID, payload) if err != nil { return @@ -352,58 +254,6 @@ func (s *Session) emit(kind string, payload any) { } select { case s.out <- env: - case <-ctx.Done(): - } -} - -func (s *Session) Cancel(ctx context.Context) error { - if s.executor != nil { - return s.cancelTurn(ctx) - } - if s.req.StrictResume && !s.req.DisableSubagents { - if err := s.cancelSubagents(ctx); err != nil { - s.process.Cancel() - return err - } - } - s.process.Cancel() - if !s.req.StrictResume { - return nil + case <-s.outputContext.Done(): } - select { - case <-s.exited: - case <-ctx.Done(): - return ctx.Err() - } - select { - case <-s.finished: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func (s *Session) SubmitPermission(_ context.Context, id string, decision proto.PermissionDecisionPayload) error { - s.mu.Lock() - defer s.mu.Unlock() - pending, ok := s.permissions[id] - if !ok { - return agent.ErrUnknownPermission - } - choice := pending.Deny - if decision.Approved { - choice = pending.Allow - } - if choice == "" { - return errors.New("mcode: ACP permission option is unavailable") - } - if len(decision.UpdatedInput) > 0 { - return errors.New("mcode: edited permission input is not supported") - } - raw, _ := json.Marshal(map[string]any{"outcome": map[string]string{"outcome": "selected", "optionId": choice}}) - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: pending.RPCID, Result: raw}); err != nil { - return err - } - delete(s.permissions, id) - return nil } diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index f2af87a50..0dfc9e070 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -4,31 +4,27 @@ import ( "bufio" "context" "encoding/json" - "errors" "os" "path/filepath" "strings" "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) 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"), AgentOptions: map[string]any{ + return proto.PromptRequestPayload{RunID: "run-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), AgentOptions: map[string]any{ "model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/v1", "api_key": "fixture-key", "context_window": 64000, "max_output_tokens": 4096}, "system_prompt": "Current instructions", - }} + }, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} } -func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { +// helperRequest selects a protocol fixture scenario as the native CLI. +func helperRequest(t *testing.T, scenario string, resume bool) proto.PromptRequestPayload { t.Helper() req := testRequest(t) - if scenario == "strict-cancel" { - req = executionRequest(t) - } if resume { req.AgentSessionID = "native-1" } @@ -42,21 +38,54 @@ func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } + t.Setenv("OAC_RUNTIME_MCODE_BIN", binary) + return req +} + +// prepareExecutor prepares req without its Turn input; cleanup closes the +// Executor and reaps its CLI. +func prepareExecutor(t *testing.T, ctx context.Context, req proto.PromptRequestPayload) (*executor, error) { + t.Helper() + req.RunID, req.Input = "", nil + value, err := NewExecutorFactory(nil)(ctx, req) + if value == nil { + return nil, err + } + e := value.(*executor) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := e.Close(cleanup); err != nil { + t.Error("CLI was not reaped:", err) + } + }) + return e, err +} + +// startTurn prepares an Executor for req and starts req.Input as its Turn. +func startTurn(t *testing.T, ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (*Session, error) { + t.Helper() + e, err := prepareExecutor(t, ctx, req) + if err != nil { + return nil, err + } + turn, err := e.StartTurn(ctx, req.RunID, req.Input, out) + if err != nil { + return nil, err + } + return turn.(*Session), nil +} + +func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { + t.Helper() + req := helperRequest(t, scenario, resume) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) out := make(chan proto.Envelope, 32) - session, err := newSession(ctx, req, out, binary) + session, err := startTurn(t, ctx, req, out) if err != nil { t.Fatal(err) } - t.Cleanup(func() { - _ = session.Cancel(context.Background()) - select { - case <-session.exited: - case <-time.After(3 * time.Second): - t.Error("CLI was not reaped") - } - }) return session, out } @@ -98,39 +127,20 @@ func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { } } -func TestSessionInteractionRoundTrip(t *testing.T) { - for _, approved := range []bool{true, false} { - t.Run(map[bool]string{true: "allow", false: "deny"}[approved], func(t *testing.T) { - session, out := helperSession(t, "interaction", false) - permissions, questions := 0, 0 - for event := range out { - switch event.Type { - case proto.TypeError: - t.Fatalf("error: %s", event.Payload) - case proto.TypePermissionRequest: - permissions++ - var p proto.PermissionRequestPayload - _ = json.Unmarshal(event.Payload, &p) - if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: approved}); err != nil { - t.Fatal(err) - } - if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { - t.Fatalf("duplicate approval: %v", err) - } - case proto.TypePromptForUserChoice: - questions++ - var p proto.PromptForUserChoicePayload - _ = json.Unmarshal(event.Payload, &p) - decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{"Europe"}}}} - if err := session.SubmitPromptForUserChoice(context.Background(), p.AskID, decision); err != nil { - t.Fatal(err) - } - } - } - if permissions != 1 || questions != 1 { - t.Fatalf("permissions=%d questions=%d", permissions, questions) - } - }) +// Harnesses run unattended: native asks are declined in the adapter and the turn completes. +func TestSessionDeclinesNativeAsks(t *testing.T) { + _, out := helperSession(t, "unattended", false) + done := 0 + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("error: %s", event.Payload) + case proto.TypeDone: + done++ + } + } + if done != 1 { + t.Fatalf("done=%d", done) } } @@ -151,34 +161,41 @@ func TestResumeSelectsModelWhenNativeSelectorIsMissing(t *testing.T) { } func TestSessionFailuresAreReported(t *testing.T) { - for _, scenario := range []string{"malformed", "exit", "rpc-error", "unknown-model"} { + for _, scenario := range []string{"malformed", "exit", "unknown-model"} { t.Run(scenario, func(t *testing.T) { - _, out := helperSession(t, scenario, false) - reported := false - for event := range out { - if event.Type == proto.TypeError { - reported = true - } - } - if !reported { - t.Fatal("failure was not emitted") + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if e, err := prepareExecutor(t, ctx, helperRequest(t, scenario, false)); err == nil || e != nil { + t.Fatal("preparation failure was not reported", err) } }) } + t.Run("rpc-error", func(t *testing.T) { + _, out := helperSession(t, "rpc-error", false) + reported := false + for event := range out { + if event.Type == proto.TypeError { + reported = true + } + } + if !reported { + t.Fatal("failure was not emitted") + } + }) } -func TestCancelStopsWaitingCLI(t *testing.T) { - session, out := helperSession(t, "hang", false) - if err := session.Cancel(context.Background()); err != nil { - t.Fatal(err) +func TestPreparationCancellationStopsWaitingCLI(t *testing.T) { + req := helperRequest(t, "hang", false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + time.AfterFunc(100*time.Millisecond, cancel) + started := time.Now() + if e, err := prepareExecutor(t, ctx, req); err == nil || e != nil { + t.Fatal("cancelled preparation retained the CLI", err) } - select { - case <-session.exited: - case <-time.After(3 * time.Second): + if time.Since(started) > 3*time.Second { t.Fatal("cancel hung") } - for range out { - } } func TestMCodeProcess(t *testing.T) { @@ -234,6 +251,9 @@ func TestMCodeProcess(t *testing.T) { result := any(map[string]any{}) switch frame.Method { case "initialize": + if scenario == "unattended" && strings.Contains(string(frame.Params), "elicitation") { + os.Exit(7) + } result = map[string]int{"protocolVersion": 1} case "session/new", "session/load": if scenario == "prepared-mcp-cancel" { @@ -272,7 +292,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") { @@ -286,7 +306,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}) @@ -302,7 +322,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 @@ -318,14 +338,14 @@ func TestMCodeProcess(t *testing.T) { send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32603, Message: "Fixture provider unavailable"}}) continue } - if scenario == "interaction" { + if scenario == "unattended" { promptID = frame.ID - send(map[string]any{"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission", "params": map[string]any{"sessionId": "native-1", "toolCall": map[string]any{"toolCallId": "tool-1", "name": "Bash", "title": "Run fixture", "rawInput": map[string]any{"command": "echo fixture"}}, "options": []map[string]string{{"optionId": "once", "kind": "allow_once"}, {"optionId": "always", "kind": "allow_always"}, {"optionId": "deny", "kind": "reject_once"}}}}) + send(map[string]any{"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission", "params": map[string]any{"sessionId": "native-1", "toolCall": map[string]any{"toolCallId": "tool-1", "name": "Bash", "title": "Run fixture", "rawInput": map[string]any{"command": "echo fixture"}}, "options": []map[string]string{{"optionId": "once", "kind": "allow_once"}, {"optionId": "deny", "kind": "reject_once"}}}}) continue } 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"}) } @@ -359,21 +379,15 @@ func TestMCodeProcess(t *testing.T) { if string(frame.ID) == `"permission-1"` { var reply struct { Outcome struct { - Option string `json:"optionId"` + Outcome string `json:"outcome"` } `json:"outcome"` } - _ = json.Unmarshal(frame.Result, &reply) - if reply.Outcome.Option != "once" && reply.Outcome.Option != "deny" { + if json.Unmarshal(frame.Result, &reply) != nil || reply.Outcome.Outcome != "cancelled" { os.Exit(5) } - send(map[string]any{"jsonrpc": "2.0", "id": "question-1", "method": "elicitation/create", "params": map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "required": []string{"region"}, "properties": map[string]any{"region": map[string]any{"type": "string", "title": "Region?", "oneOf": []map[string]string{{"const": "eu", "title": "Europe"}, {"const": "us", "title": "America"}}}}}}}) + send(map[string]any{"jsonrpc": "2.0", "id": "question-1", "method": "elicitation/create", "params": map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "properties": map[string]any{"region": map[string]any{"type": "string"}}}}}) } else { - var reply struct { - Action string `json:"action"` - Content map[string]string `json:"content"` - } - _ = json.Unmarshal(frame.Result, &reply) - if reply.Action != "accept" || reply.Content["region"] != "eu" { + if frame.Error == nil || frame.Error.Code != -32601 { os.Exit(6) } raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) diff --git a/apps/daemon/internal/agent/mcode/steering.go b/apps/daemon/internal/agent/mcode/steering.go index bff6f079f..005f5c859 100644 --- a/apps/daemon/internal/agent/mcode/steering.go +++ b/apps/daemon/internal/agent/mcode/steering.go @@ -18,16 +18,14 @@ func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) err // Native acceptance belongs to the active ACP Turn; it does not promise model consumption. func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { - if s.executor != nil { - s.mu.Lock() - if s.closing || s.cancelled { - s.mu.Unlock() - return agent.ErrSteeringInactive - } - s.operations.Add(1) + s.mu.Lock() + if s.closing || s.cancelled { s.mu.Unlock() - defer s.operations.Done() + return agent.ErrSteeringInactive } + s.operations.Add(1) + s.mu.Unlock() + defer s.operations.Done() text, err := input.Input.TextOnly() if strings.TrimSpace(input.InputID) == "" || err != nil { return agent.ErrSteeringRejected @@ -62,7 +60,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP var stopped error select { case frame = <-response: - case <-s.inputSettlementDone(): + case <-s.inputDone: stopped = fmt.Errorf("mcode: run ended with unknown input outcome") case <-s.exited: stopped = fmt.Errorf("mcode: input transport closed") @@ -103,13 +101,6 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP return nil } -func (s *Session) inputSettlementDone() <-chan struct{} { - if s.inputDone != nil { - return s.inputDone - } - return s.finished -} - func (s *Session) CancellationOutcome() proto.DonePayload { s.mu.Lock() defer s.mu.Unlock() 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/subagent_cancel.go b/apps/daemon/internal/agent/mcode/subagent_cancel.go index bd1b88e68..5efa5a031 100644 --- a/apps/daemon/internal/agent/mcode/subagent_cancel.go +++ b/apps/daemon/internal/agent/mcode/subagent_cancel.go @@ -6,21 +6,6 @@ import ( "fmt" ) -func (s *Session) cancelSubagents(ctx context.Context) error { - if err := s.stopSubagents(ctx); err != nil { - return err - } - select { - case <-s.finished: - s.mu.Lock() - err := s.subagentSettlementError - s.mu.Unlock() - return err - case <-ctx.Done(): - return ctx.Err() - } -} - func (s *Session) stopSubagents(ctx context.Context) error { select { case <-s.finished: diff --git a/apps/daemon/internal/agent/mcode/subagents_test.go b/apps/daemon/internal/agent/mcode/subagents_test.go index bccb15597..25a1773af 100644 --- a/apps/daemon/internal/agent/mcode/subagents_test.go +++ b/apps/daemon/internal/agent/mcode/subagents_test.go @@ -27,7 +27,7 @@ func childSnapshot(t *testing.T) nativeSubagentSnapshot { func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { project := func(snapshot nativeSubagentSnapshot) []proto.Envelope { out := make(chan proto.Envelope, 32) - s := &Session{ctx: context.Background(), out: out, previousNativeTurns: map[string]bool{}} + s := &Session{ctx: context.Background(), outputContext: context.Background(), out: out, previousNativeTurns: map[string]bool{}} if err := s.projectSubagents(snapshot); err != nil { t.Fatal(err) } @@ -63,7 +63,7 @@ func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { snapshot := childSnapshot(t) snapshot.Sessions[0].Tasks = nil - s := &Session{ctx: context.Background(), out: make(chan proto.Envelope, 32)} + s := &Session{ctx: context.Background(), outputContext: context.Background(), out: make(chan proto.Envelope, 32)} if err := s.projectSubagents(snapshot); err == nil { t.Fatal("accepted child without original spawning provenance") } 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..703e3cb90 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(), outputContext: 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/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go index a92be890d..e221bd595 100644 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ b/apps/daemon/internal/agent/mcode/unsupported.go @@ -34,15 +34,3 @@ func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.Wo func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go index a059b11ef..eb0609bb6 100644 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ b/apps/daemon/internal/agent/mcode/unsupported_test.go @@ -27,21 +27,21 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { } var turn *Session check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) - for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) if len(result.Data) != 0 || result.Truncated { t.Fatal("unsupported read fabricated data") } } - for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil)} { result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) check(err) if len(result.Entries) != 0 || result.Truncated { t.Fatal("unsupported listing fabricated entries") } } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index 5c8f5aa6d..5b913e4ac 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" "encoding/json" "fmt" "os" @@ -50,11 +49,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, err := runtimeMCP(req) @@ -67,7 +66,7 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P private.LocalEnvironment, private.DisableExecutionEnvironment = nil, true // Public declarations have already been resolved into the transient ACP map. private.MCPHTTPServers = nil - opts, err := prepareOptionsWithSkills(ctx, private, false) + opts, err := prepareOptions(private) if err != nil { return opts, err } 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/agent/registry.go b/apps/daemon/internal/agent/registry.go index 4c6b5e815..c6998e918 100644 --- a/apps/daemon/internal/agent/registry.go +++ b/apps/daemon/internal/agent/registry.go @@ -1,7 +1,6 @@ package agent import ( - "errors" "fmt" "slices" "sync" @@ -10,24 +9,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) -// ErrUnknownPermission is returned by PermissionResponder.SubmitPermission when -// the permID doesn't match any outstanding request. The router uses -// this to distinguish a benign race from a real forwarding failure. -var ErrUnknownPermission = errors.New("agent: unknown permission id") - -// ErrUnknownAsk is returned by UserChoiceResponder.SubmitPromptForUserChoice when -// the askID doesn't match any outstanding ask. Same race semantics as -// ErrUnknownPermission. -var ErrUnknownAsk = errors.New("agent: unknown ask id") - -var ErrUnsupportedKind = errors.New("agent: unsupported agent_kind") - -// Registry maps agent_kind → Factory and keeps the daemon-advertised -// capability descriptor for each kind. Safe for concurrent use. +// Registry keeps the daemon-advertised capability descriptor and execution +// factories for each agent_kind. Safe for concurrent use. type Registry struct { mu sync.RWMutex - factories map[string]Factory - preparers map[string]PreparationFactory executors map[string]ExecutorFactory kinds map[string]proto.SupportedAgentKind configurations map[string]harnessconfig.Configuration @@ -35,45 +20,19 @@ type Registry struct { func NewRegistry() *Registry { return &Registry{ - factories: make(map[string]Factory), - preparers: make(map[string]PreparationFactory), executors: make(map[string]ExecutorFactory), kinds: make(map[string]proto.SupportedAgentKind), configurations: make(map[string]harnessconfig.Configuration), } } -// Resolve returns the factory for kind, or wraps ErrUnsupportedKind. -func (r *Registry) Resolve(kind string) (Factory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - f, ok := r.factories[kind] - if !ok { - return nil, fmt.Errorf("%w: %q", ErrUnsupportedKind, kind) - } - return f, nil -} - -// Kinds returns the list of registered kinds sorted lexicographically. -func (r *Registry) Kinds() []string { - r.mu.RLock() - defer r.mu.RUnlock() - out := make([]string, 0, len(r.factories)) - for k := range r.factories { - out = append(out, k) - } - slices.Sort(out) - return out -} - // SupportedAgentKinds returns the daemon-advertised capability // descriptors sorted by kind so heartbeat payloads are stable. func (r *Registry) SupportedAgentKinds() []proto.SupportedAgentKind { r.mu.RLock() defer r.mu.RUnlock() - out := make([]proto.SupportedAgentKind, 0, len(r.factories)) - for kind := range r.factories { - info := r.kinds[kind] + out := make([]proto.SupportedAgentKind, 0, len(r.kinds)) + for _, info := range r.kinds { out = append(out, info) } slices.SortFunc(out, func(a, b proto.SupportedAgentKind) int { @@ -97,25 +56,3 @@ func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { } return factory, nil } - -func (r *Registry) ResolvePreparation(kind string) (PreparationFactory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - f := r.preparers[kind] - if f == nil { - return nil, fmt.Errorf("agent: preparation unavailable for %q", kind) - } - return f, nil -} - -// Configuration returns an owned declaration for registry wrappers. Wrappers -// transfer it with the factory; they must not infer configuration from kind names. -func (r *Registry) Configuration(kind string) (harnessconfig.Configuration, error) { - r.mu.RLock() - defer r.mu.RUnlock() - configuration, ok := r.configurations[kind] - if !ok { - return harnessconfig.Configuration{}, ErrUnsupportedKind - } - return configuration.Clone(), nil -} diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index c41b6269a..3d484d6dc 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -6,7 +6,6 @@ import ( "context" "errors" "reflect" - "slices" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -14,77 +13,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) -func stubFactory(marker string) agent.Factory { - return func(_ context.Context, _ proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - return stubSession{marker: marker}, nil - } -} - -type stubSession struct{ marker string } - -func (stubSession) CancellationOutcome() proto.DonePayload { return proto.DonePayload{} } - -func (stubSession) Cancel(context.Context) error { return nil } -func (stubSession) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { - return nil -} -func (stubSession) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { - return nil -} - -func TestRegistryResolveReturnsRegisteredFactory(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - - f, err := reg.Resolve("fake_alpha") - if err != nil { - t.Fatalf("Resolve: %v", err) - } - sess, err := f(context.Background(), proto.PromptRequestPayload{AgentKind: "fake_alpha"}, nil) - if err != nil { - t.Fatalf("factory: %v", err) - } - stub, ok := sess.(stubSession) - if !ok || stub.marker != "cc" { - t.Errorf("resolved factory returned %#v, want stubSession{marker:\"cc\"}", sess) - } -} - -func TestRegistryResolveUnknownKindReturnsTypedError(t *testing.T) { +func TestRegistryRegisterOverwritesDescriptor(t *testing.T) { reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Version: "v1", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Version: "v2", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) - _, err := reg.Resolve("fake_beta") - if !errors.Is(err, agent.ErrUnsupportedKind) { - t.Errorf("Resolve unknown = %v, want ErrUnsupportedKind chain", err) - } -} - -func TestRegistryRegisterOverwrites(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("v1")) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("v2")) - - f, err := reg.Resolve("k") - if err != nil { - t.Fatalf("Resolve: %v", err) - } - sess, _ := f(context.Background(), proto.PromptRequestPayload{}, nil) - if got := sess.(stubSession).marker; got != "v2" { - t.Errorf("overwrite: marker = %q, want v2", got) - } -} - -func TestRegistryKindsReportsRegistered(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_beta", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("oc")) - - got := reg.Kinds() - slices.Sort(got) - want := []string{"fake_alpha", "fake_beta"} - if !slices.Equal(got, want) { - t.Errorf("Kinds = %v, want %v", got, want) + got := reg.SupportedAgentKinds() + if len(got) != 1 || got[0].Version != "v2" { + t.Errorf("overwrite: descriptors = %#v, want one v2 descriptor", got) } } @@ -94,16 +30,22 @@ func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { t.Fatal("Register(\"\", ...) did not panic") } }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("x")) + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) } -func TestRegistryRegisterPanicsOnNilFactory(t *testing.T) { +func TestRegistryRegisterRejectsFactoriesForUnavailableRuntime(t *testing.T) { + info := proto.SupportedAgentKind{Kind: "k", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})} + executor := func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, nil } + registry := agent.NewRegistry() defer func() { - if r := recover(); r == nil { - t.Fatal("Register(kind, nil) did not panic") + if recover() == nil { + t.Fatal("unavailable runtime registered factories") + } + if len(registry.SupportedAgentKinds()) != 0 { + t.Fatal("rejected runtime changed registry") } }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, nil) + registry.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, Executor: executor}) } func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { @@ -115,18 +57,17 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ Streaming: proto.CapabilitySupported, }), - }, harnessconfig.Configuration{}, stubFactory("oc")) + }, harnessconfig.Configuration{}) reg.RegisterKind(proto.SupportedAgentKind{ Kind: "fake_alpha", Available: true, Version: "1.2.3", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, + Streaming: proto.CapabilitySupported, + Usage: proto.CapabilitySupported, + Resume: proto.CapabilitySupported, }), - }, harnessconfig.Configuration{}, stubFactory("cc")) + }, harnessconfig.Configuration{}) got := reg.SupportedAgentKinds() if len(got) != 2 { @@ -135,7 +76,7 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { if got[0].Kind != "fake_alpha" || got[1].Kind != "fake_beta" { t.Fatalf("SupportedAgentKinds sort = %#v, want fake_alpha then fake_beta", got) } - if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.Permissions.IsSupported() || !got[0].Capabilities.Resume.IsSupported() { + if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.Usage.IsSupported() || !got[0].Capabilities.Resume.IsSupported() { t.Fatalf("fake_alpha descriptor not preserved: %#v", got[0]) } if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.Streaming.IsSupported() { @@ -145,9 +86,9 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("native")) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) if _, err := registry.ResolveExecutor("native"); err == nil { - t.Fatal("legacy factory implied reusable execution") + t.Fatal("kind registration implied reusable execution") } expected := errors.New("executor factory") registry.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, expected }) @@ -158,7 +99,7 @@ func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { if _, err := factory(t.Context(), proto.PromptRequestPayload{}); !errors.Is(err, expected) { t.Fatal(err) } - registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("replacement")) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) if _, err := registry.ResolveExecutor("native"); err == nil { t.Fatal("replacing a kind retained its old executor capability") } @@ -169,7 +110,10 @@ func TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement(t *testing.T) { for i := 0; i < reflect.TypeOf(valid).NumField(); i++ { t.Run(reflect.TypeOf(valid).Field(i).Name, func(t *testing.T) { registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: valid}, harnessconfig.Configuration{}, stubFactory("original")) + original := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: valid} + registry.RegisterKind(original, harnessconfig.Configuration{}) + registry.RegisterExecutor("fixture", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, nil }) + original.Capabilities.Preparation = proto.CapabilitySupported missing := valid reflect.ValueOf(&missing).Elem().Field(i).Set(reflect.ValueOf(proto.CapabilityUnspecified)) func() { @@ -178,14 +122,9 @@ func TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement(t *testing.T) { t.Error("incomplete declaration registered") } }() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: false, Capabilities: missing}, harnessconfig.Configuration{}, stubFactory("replacement")) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: false, Capabilities: missing}, harnessconfig.Configuration{}) }() - factory, err := registry.Resolve("fixture") - if err != nil { - t.Fatal(err) - } - session, err := factory(t.Context(), proto.PromptRequestPayload{}, nil) - if err != nil || session.(stubSession).marker != "original" { + if _, err := registry.ResolveExecutor("fixture"); err != nil || !reflect.DeepEqual(registry.SupportedAgentKinds(), []proto.SupportedAgentKind{original}) { t.Fatal("failed declaration changed registry") } }) diff --git a/apps/daemon/internal/agent/runtime_paths.go b/apps/daemon/internal/agent/runtime_paths.go index 5f78a1405..456231282 100644 --- a/apps/daemon/internal/agent/runtime_paths.go +++ b/apps/daemon/internal/agent/runtime_paths.go @@ -8,32 +8,22 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) -// ManagedSkillsRoot returns an adapter-owned skill directory scoped to one -// agent state. It never derives runtime state from the subprocess cwd. -func ManagedSkillsRoot(agentKind, agentStateKey, conversationID, runID string) (string, error) { +// StateDir returns an adapter-owned state directory scoped to one agent +// state. It never derives runtime state from the subprocess cwd. +func StateDir(agentKind, agentStateKey string) (string, error) { root, err := paths.Root() if err != nil { - return "", fmt.Errorf("agent: resolve managed skills root: %w", err) + return "", fmt.Errorf("agent: resolve state directory: %w", err) } kind := safeRuntimePathPart(agentKind) if kind == "" { return "", fmt.Errorf("agent: invalid agent kind %q", agentKind) } - base := filepath.Join(root, "runtime", kind) - if key := strings.TrimSpace(agentStateKey); key != "" { - parts := safeRuntimePathParts(key) - if len(parts) == 0 { - return "", fmt.Errorf("agent: invalid agent state key %q", agentStateKey) - } - return filepath.Join(append([]string{base, "state"}, append(parts, "skills")...)...), nil - } - if id := safeRuntimePathPart(conversationID); id != "" { - return filepath.Join(base, "conv-"+id, "skills"), nil - } - if id := safeRuntimePathPart(runID); id != "" { - return filepath.Join(base, "run-"+id, "skills"), nil + parts := safeRuntimePathParts(agentStateKey) + if len(parts) == 0 { + return "", fmt.Errorf("agent: invalid agent state key %q", agentStateKey) } - return "", fmt.Errorf("agent: agent state key, conversation id, or run id is required") + return filepath.Join(append([]string{root, "runtime", kind, "state"}, parts...)...), nil } func safeRuntimePathParts(value string) []string { diff --git a/apps/daemon/internal/agent/runtime_paths_test.go b/apps/daemon/internal/agent/runtime_paths_test.go index 8cf35e5dc..3e98c1792 100644 --- a/apps/daemon/internal/agent/runtime_paths_test.go +++ b/apps/daemon/internal/agent/runtime_paths_test.go @@ -5,28 +5,29 @@ import ( "testing" ) -func TestManagedSkillsRootUsesStableAgentState(t *testing.T) { +func TestStateDirUsesStableAgentState(t *testing.T) { home := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", home) - got, err := ManagedSkillsRoot("codex", "conv-1/agent-1/codex", "ignored", "ignored") + got, err := StateDir("codex", "conv-1/agent-1/codex") if err != nil { - t.Fatalf("ManagedSkillsRoot: %v", err) + t.Fatalf("StateDir: %v", err) } - want := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills") + want := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex") if got != want { t.Fatalf("root = %q, want %q", got, want) } } -func TestManagedSkillsRootSanitizesFallback(t *testing.T) { +func TestStateDirRequiresAgentState(t *testing.T) { home := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", home) - got, err := ManagedSkillsRoot("fake_beta", "", "../conv name", "ignored") - if err != nil { - t.Fatalf("ManagedSkillsRoot: %v", err) + for _, key := range []string{"", " ", "../.."} { + if _, err := StateDir("codex", key); err == nil { + t.Fatalf("state key %q accepted", key) + } } - want := filepath.Join(home, "runtime", "fake_beta", "conv-.._conv_name", "skills") - if got != want { - t.Fatalf("root = %q, want %q", got, want) + got, err := StateDir("codex", "../session-1/a b") + if want := filepath.Join(home, "runtime", "codex", "state", "session-1", "a_b"); err != nil || got != want { + t.Fatalf("sanitized state = %q, %v; want %q", got, err, want) } } diff --git a/apps/daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go index 3a69123e8..ec636f67e 100644 --- a/apps/daemon/internal/auth/store.go +++ b/apps/daemon/internal/auth/store.go @@ -1,9 +1,7 @@ -// Package auth persists the credential bundle from -// /api/v1/runtimes/pair: server URL, runtime row id (= device_id), and -// the long-lived runner_credential. Stored as JSON per-profile at -// ~/.oac/daemon//auth.json (0o600), written via -// atomic rename so a half-flushed pair never leaves the daemon paired -// with garbage state. +// 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 ( @@ -11,21 +9,19 @@ import ( "errors" "fmt" "os" - "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) -// Profile is the on-disk representation of one paired credential. +// 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 runtimes row id minted at pair time. 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 @@ -35,57 +31,11 @@ type Profile struct { RunnerCredential string `json:"runner_credential"` DeviceName string `json:"device_name,omitempty"` - - Hostname string `json:"hostname,omitempty"` - - // PairedAt is when the credential was minted. `omitzero` because - // `omitempty` doesn't elide zero structs like time.Time. - PairedAt time.Time `json:"paired_at,omitzero"` - - // RunnerPublicKey is the base64 X25519 public half generated at - // pair time. Server stores the matching value in - // runtimes.config.runner_public_key for SealAnonymous addressed - // to this daemon. - RunnerPublicKey string `json:"runner_public_key,omitempty"` - - // RunnerPrivateKey is the base64 X25519 private half — used by - // runtimecrypto.OpenSeal to decrypt incoming sealed payloads. - // MUST NEVER appear in logs or leave the box. - RunnerPrivateKey string `json:"runner_private_key,omitempty"` } // ErrNotPaired is returned by Load when no auth.json exists for the // requested profile. -var ErrNotPaired = errors.New("auth: not paired — use `oac-daemon connect --url ... --token ...`") - -// Save writes p atomically to the profile's auth.json (0o600 even if -// the previous file was world-readable). -func Save(profile string, p Profile) error { - if profile == "" { - return fmt.Errorf("auth: profile name required") - } - dir, err := paths.EnsureProfileDir(profile) - if err != nil { - return err - } - raw, err := json.MarshalIndent(p, "", " ") - if err != nil { - return errors.New("auth: could not encode profile") - } - if err = runtimefs.EnsurePrivateDir(dir); err != nil { - return errors.New("auth: private profile unavailable") - } - held, err := os.OpenRoot(dir) - if err != nil { - return errors.New("auth: private profile unavailable") - } - defer held.Close() - if err = runtimefs.WritePrivateAtomic(held, "auth.json", raw); err != nil { - return errors.New("auth: private profile write failed") - } - - return nil -} +var ErrNotPaired = errors.New("auth: no daemon credential profile — create one with oac-core-device") // Load reads the profile's auth.json. Returns ErrNotPaired wrapping // fs.ErrNotExist when the file is missing. diff --git a/apps/daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go index 1d76ad1b7..1d3bfffcc 100644 --- a/apps/daemon/internal/auth/store_test.go +++ b/apps/daemon/internal/auth/store_test.go @@ -1,16 +1,16 @@ package auth_test import ( + "encoding/json" "errors" "io/fs" "os" "path/filepath" - "runtime" "testing" - "time" "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 withTempHome(t *testing.T) string { @@ -20,105 +20,62 @@ func withTempHome(t *testing.T) string { return dir } -func TestSaveLoadRoundTrip(t *testing.T) { - _ = withTempHome(t) - now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC) - want := auth.Profile{ - ServerURL: "https://core.example.com", - RuntimeID: "rt_abc123", - RunnerCredential: "secret-credential", - DeviceName: "alice-mac", - Hostname: "alice-mac.local", - PairedAt: now, - } - if err := auth.Save("test", want); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := auth.Load("test") +func profileDir(t *testing.T, profile string) string { + t.Helper() + dir, err := paths.ProfileDir(profile) if err != nil { - t.Fatalf("Load: %v", err) + t.Fatalf("ProfileDir: %v", err) } - if got.ServerURL != want.ServerURL || - got.RuntimeID != want.RuntimeID || - got.RunnerCredential != want.RunnerCredential || - got.DeviceName != want.DeviceName || - got.Hostname != want.Hostname || - !got.PairedAt.Equal(want.PairedAt) { - t.Fatalf("Load round-trip mismatch:\n got=%+v\nwant=%+v", got, want) + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + t.Fatalf("EnsurePrivateDir: %v", err) } + return dir } -func TestSaveSetsRestrictivePerms(t *testing.T) { - _ = withTempHome(t) - if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil { - t.Fatalf("Save: %v", err) - } - authPath, err := paths.AuthFile("default") - if err != nil { - t.Fatalf("AuthFile: %v", err) - } - info, err := os.Stat(authPath) +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.Fatalf("stat auth.json: %v", err) + t.Fatalf("marshal profile: %v", err) } - // Unix stores credentials with 0600; Windows uses normal account ACLs, - // which are not represented by Go permission bits. - if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { - t.Errorf("auth.json perm = %o, want 0600", mode) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), raw, 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) } } -func TestSaveIsAtomicNoStrayTempFile(t *testing.T) { +func TestLoadReadsProfile(t *testing.T) { _ = withTempHome(t) - if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil { - t.Fatalf("Save: %v", err) - } - authPath, err := paths.AuthFile("default") - if err != nil { - t.Fatalf("AuthFile: %v", err) + want := auth.Profile{ + ServerURL: "https://core.example.com", + RuntimeID: "rt_abc123", + RunnerCredential: "secret-credential", + DeviceName: "alice-mac", } - // Writes to auth.json.tmp then renames — no stray .tmp on success. - entries, err := os.ReadDir(filepath.Dir(authPath)) + writeProfile(t, "test", want) + got, err := auth.Load("test") if err != nil { - t.Fatalf("ReadDir: %v", err) + t.Fatalf("Load: %v", err) } - for _, e := range entries { - if filepath.Ext(e.Name()) == ".tmp" { - t.Fatalf("found stray temp file after Save: %s", e.Name()) - } + if got != want { + t.Fatalf("Load mismatch:\n got=%+v\nwant=%+v", got, want) } } -func TestSaveOverwritesAndHealsPerms(t *testing.T) { +func TestLoadIgnoresLegacyProfileFields(t *testing.T) { _ = withTempHome(t) - if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt1", RunnerCredential: "c1"}); err != nil { - t.Fatalf("Save first: %v", err) - } - authPath, err := paths.AuthFile("default") - if err != nil { - t.Fatalf("AuthFile: %v", err) - } - // Simulate a previously-world-readable file (user chmod'd it); - // atomic-rename Save must re-establish 0600 on the new inode. - if err := os.Chmod(authPath, 0o644); err != nil { - t.Fatalf("chmod loose perms: %v", err) - } - if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt2", RunnerCredential: "c2"}); err != nil { - t.Fatalf("Save second: %v", err) - } - info, err := os.Stat(authPath) - if err != nil { - t.Fatalf("stat: %v", err) - } - if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { - t.Errorf("perm after re-save = %o, want 0600 (healing failed)", mode) + 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("default") + got, err := auth.Load("legacy") if err != nil { t.Fatalf("Load: %v", err) } - if got.RuntimeID != "rt2" || got.RunnerCredential != "c2" { - t.Errorf("overwrite did not take effect: %+v", got) + 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) } } @@ -137,14 +94,11 @@ func TestLoadMissingReturnsErrNotPaired(t *testing.T) { func TestLoadCorruptJSONReturnsError(t *testing.T) { _ = withTempHome(t) - dir, err := paths.EnsureProfileDir("default") - if err != nil { - t.Fatalf("EnsureProfileDir: %v", err) - } + 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") + _, err := auth.Load("default") if err == nil { t.Fatal("Load returned nil error on corrupt JSON") } @@ -158,9 +112,7 @@ func TestDeleteIsIdempotent(t *testing.T) { if err := auth.Delete("default"); err != nil { t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err) } - if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil { - t.Fatalf("Save: %v", 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) } @@ -172,10 +124,3 @@ func TestDeleteIsIdempotent(t *testing.T) { t.Fatalf("Delete second call = %v, want nil", err) } } - -func TestSaveRejectsEmptyProfile(t *testing.T) { - _ = withTempHome(t) - if err := auth.Save("", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err == nil { - t.Fatal("Save with empty profile should error") - } -} diff --git a/apps/daemon/internal/authoring/bridge.go b/apps/daemon/internal/authoring/bridge.go deleted file mode 100644 index 519165fda..000000000 --- a/apps/daemon/internal/authoring/bridge.go +++ /dev/null @@ -1,134 +0,0 @@ -package authoring - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "path/filepath" - "sync" - "time" - - "github.com/google/uuid" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type Sender interface { - Send(context.Context, proto.Envelope) error -} - -type Bridge struct { - sender Sender - mu sync.Mutex - waiters map[string]waiter -} - -type waiter struct { - runID string - response chan proto.AuthoringResponsePayload -} - -func New(sender Sender) *Bridge { - return &Bridge{sender: sender, waiters: make(map[string]waiter)} -} - -// Deliver only resolves the matching request from the same active run. -func (b *Bridge) Deliver(env proto.Envelope) { - var response proto.AuthoringResponsePayload - if env.DecodePayload(&response) != nil { - return - } - b.mu.Lock() - w, ok := b.waiters[response.RequestID] - b.mu.Unlock() - if ok && w.runID == env.ID { - select { - case w.response <- response: - default: - } - } -} - -func (b *Bridge) request(ctx context.Context, runID string, request proto.AuthoringRequestPayload) (proto.AuthoringResponsePayload, error) { - request.RequestID = uuid.NewString() - w := waiter{runID: runID, response: make(chan proto.AuthoringResponsePayload, 1)} - b.mu.Lock() - b.waiters[request.RequestID] = w - b.mu.Unlock() - defer func() { b.mu.Lock(); delete(b.waiters, request.RequestID); b.mu.Unlock() }() - env, err := proto.NewEnvelope(proto.TypeAuthoringRequest, runID, request) - if err != nil { - return proto.AuthoringResponsePayload{}, err - } - if err := b.sender.Send(ctx, env); err != nil { - return proto.AuthoringResponsePayload{}, err - } - select { - case response := <-w.response: - return response, nil - case <-ctx.Done(): - return proto.AuthoringResponsePayload{}, ctx.Err() - } -} - -func (b *Bridge) Listen(parent context.Context, runID string) (string, func(), error) { - home, err := os.UserHomeDir() - if err != nil { - return "", nil, err - } - dir := filepath.Join(home, ".oac", "authoring") - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", nil, err - } - path := filepath.Join(dir, uuid.NewString()[:8]+".sock") - listener, err := net.Listen("unix", path) - if err != nil { - return "", nil, fmt.Errorf("open daemon authoring socket: %w", err) - } - if err := os.Chmod(path, 0o600); err != nil { - _ = listener.Close() - return "", nil, err - } - ctx, cancel := context.WithCancel(parent) - stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) - closeListener := func() { cancel(); stop(); _ = listener.Close() } - go func() { - defer closeListener() - for { - conn, err := listener.Accept() - if err != nil { - return - } - go b.serve(ctx, runID, conn) - } - }() - return path, closeListener, nil -} - -func (b *Bridge) serve(parent context.Context, runID string, conn net.Conn) { - defer func() { _ = conn.Close() }() - ctx, cancel := context.WithTimeout(parent, 30*time.Second) - defer cancel() - stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) - defer stop() - _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) - var request proto.AuthoringRequestPayload - decoder := json.NewDecoder(io.LimitReader(conn, proto.AuthoringMaxBytes+1)) - decoder.DisallowUnknownFields() - err := decoder.Decode(&request) - if err == nil && decoder.InputOffset() > proto.AuthoringMaxBytes { - err = errors.New("authoring request is too large") - } - var response proto.AuthoringResponsePayload - if err == nil { - response, err = b.request(ctx, runID, request) - } - if err != nil { - response.Error = err.Error() - } - _ = json.NewEncoder(conn).Encode(response) -} diff --git a/apps/daemon/internal/authoring/bridge_test.go b/apps/daemon/internal/authoring/bridge_test.go deleted file mode 100644 index 17056693d..000000000 --- a/apps/daemon/internal/authoring/bridge_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package authoring - -import ( - "context" - "encoding/json" - "net" - "os" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type testSender struct{ frames chan proto.Envelope } - -func (s testSender) Send(ctx context.Context, env proto.Envelope) error { - select { - case s.frames <- env: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func TestBridgeUsesRunAttributionAndClosesAccess(t *testing.T) { - home, err := os.MkdirTemp("/tmp", "pa-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(home) - t.Setenv("HOME", home) - sender := testSender{frames: make(chan proto.Envelope, 1)} - b := New(sender) - path, release, err := b.Listen(t.Context(), "run-a") - if err != nil { - t.Fatal(err) - } - defer release() - if filepath.Dir(path) != filepath.Join(home, ".oac", "authoring") { - t.Fatal("socket outside OpenAgentCore state") - } - if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { - t.Fatalf("socket permissions: %v %v", info, err) - } - conn, err := net.Dial("unix", path) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) - if err := json.NewEncoder(conn).Encode(proto.AuthoringRequestPayload{RequestID: "client-supplied", Operation: proto.AuthoringContext}); err != nil { - t.Fatal(err) - } - var frame proto.Envelope - select { - case frame = <-sender.frames: - case <-time.After(time.Second): - t.Fatal("request not forwarded") - } - var request proto.AuthoringRequestPayload - if frame.DecodePayload(&request) != nil || frame.ID != "run-a" || request.RequestID == "client-supplied" || frame.Type != proto.TypeAuthoringRequest { - t.Fatalf("wrong attribution: %+v", frame) - } - wrong, _ := proto.NewEnvelope(proto.TypeAuthoringResponse, "run-b", proto.AuthoringResponsePayload{RequestID: request.RequestID, Data: json.RawMessage(`"wrong"`)}) - b.Deliver(wrong) - good, _ := proto.NewEnvelope(proto.TypeAuthoringResponse, "run-a", proto.AuthoringResponsePayload{RequestID: request.RequestID, Data: json.RawMessage(`"right"`)}) - b.Deliver(good) - var response proto.AuthoringResponsePayload - if err := json.NewDecoder(conn).Decode(&response); err != nil { - t.Fatal(err) - } - if string(response.Data) != `"right"` { - t.Fatalf("wrong response: %+v", response) - } - release() - if _, err := net.DialTimeout("unix", path, time.Second); err == nil { - t.Fatal("completed run still accepts commands") - } -} - -func TestBridgeCancellationClearsWaiters(t *testing.T) { - sender := testSender{frames: make(chan proto.Envelope, 1)} - b := New(sender) - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - _, err := b.request(ctx, "run", proto.AuthoringRequestPayload{Operation: proto.AuthoringSkillList}) - done <- err - }() - <-sender.frames - cancel() - select { - case err := <-done: - if err == nil { - t.Fatal("cancelled request succeeded") - } - case <-time.After(time.Second): - t.Fatal("cancelled request remained blocked") - } - b.mu.Lock() - defer b.mu.Unlock() - if len(b.waiters) != 0 { - t.Fatal("waiter retained after cancellation") - } -} diff --git a/apps/daemon/internal/cli/agent_discovery.go b/apps/daemon/internal/cli/agent_discovery.go index d0c987806..0b96f6c34 100644 --- a/apps/daemon/internal/cli/agent_discovery.go +++ b/apps/daemon/internal/cli/agent_discovery.go @@ -32,7 +32,7 @@ func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, d } started := time.Now() runtime := declaration.Discover(parent, agent.DiscoveryOptions{Profile: profile, Stdout: rc.stdout, Stderr: rc.stderr}, declaration.Info) - obslog.Info(parent, "runtime startup stage", "stage", "harness_discovery", + obslog.Ctx(parent).Info("runtime startup stage", "stage", "harness_discovery", "harness_kind", declaration.Info.Kind, "duration_ms", float64(time.Since(started))/float64(time.Millisecond), "available", runtime != nil && runtime.Info.Available) if runtime == nil { diff --git a/apps/daemon/internal/cli/agent_registration.go b/apps/daemon/internal/cli/agent_registration.go index f2911f217..09472bf6e 100644 --- a/apps/daemon/internal/cli/agent_registration.go +++ b/apps/daemon/internal/cli/agent_registration.go @@ -2,15 +2,8 @@ package cli import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery, serverURL string) { +func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery) { for _, discovered := range discovery { - runtime := discovered.runtime - if runtime.SessionCapabilityContext { - runtime.Session = withSkillUploadServer(withCapabilityDownloads(runtime.Session, serverURL), serverURL) - } - if runtime.Executor != nil && runtime.ExecutorCapabilityContext { - runtime.Executor = withExecutorCapabilities(runtime.Executor, serverURL) - } - registry.Register(discovered.declaration, runtime) + registry.Register(discovered.declaration, discovered.runtime) } } diff --git a/apps/daemon/internal/cli/authoring.go b/apps/daemon/internal/cli/authoring.go deleted file mode 100644 index 6b3b26956..000000000 --- a/apps/daemon/internal/cli/authoring.go +++ /dev/null @@ -1,77 +0,0 @@ -package cli - -import ( - "context" - "maps" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func withAuthoringBridge(factory agent.Factory, bridge *authoring.Bridge) agent.Factory { - return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - if !req.WorkspaceAuthoring { - return factory(ctx, req, out) - } - path, release, err := bridge.Listen(ctx, req.RunID) - if err != nil { - return nil, err - } - req.AgentOptions = maps.Clone(req.AgentOptions) - if req.AgentOptions == nil { - req.AgentOptions = make(map[string]any) - } - env, _ := req.AgentOptions["env"].(map[string]any) - env = maps.Clone(env) - if env == nil { - env = make(map[string]any) - } - env[proto.AuthoringSocketEnv] = path - if executable, err := os.Executable(); err == nil { - addCompanionCLIPath(env, filepath.Dir(executable)) - } - req.AgentOptions["env"] = env - upstream := make(chan proto.Envelope, 64) - session, err := factory(ctx, req, upstream) - if err != nil { - release() - return nil, err - } - go func() { - defer close(out) - defer release() - for event := range upstream { - if event.Type == proto.TypeDone { - release() - } - out <- event - } - }() - return session, nil - } -} - -func authoringRegistry(registry *agent.Registry, bridge *authoring.Bridge) *agent.Registry { - wrapped := agent.NewRegistry() - for _, info := range registry.SupportedAgentKinds() { - factory, _ := registry.Resolve(info.Kind) - if info.Capabilities.WorkspaceAuthoring.IsSupported() { - factory = withAuthoringBridge(factory, bridge) - } - configuration, err := registry.Configuration(info.Kind) - if err != nil { - panic(err) - } - wrapped.RegisterKind(info, configuration, factory) - if executor, err := registry.ResolveExecutor(info.Kind); err == nil { - wrapped.RegisterExecutor(info.Kind, executor) - } - if prepare, err := registry.ResolvePreparation(info.Kind); err == nil { - wrapped.RegisterPreparation(info.Kind, info.Capabilities.WorkspaceReadPreparation.IsSupported(), prepare) - } - } - return wrapped -} diff --git a/apps/daemon/internal/cli/authoring_test.go b/apps/daemon/internal/cli/authoring_test.go deleted file mode 100644 index 709109256..000000000 --- a/apps/daemon/internal/cli/authoring_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package cli - -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - -import ( - "context" - "errors" - "net" - "os" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" -) - -func TestAuthoringSocketEndsWithTurnWhileSessionIsRetained(t *testing.T) { - home, err := os.MkdirTemp("/tmp", "pa-wrap-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(home) - t.Setenv("HOME", home) - env := map[string]any{"MODEL_KEY": "preserved"} - var path string - var events chan<- proto.Envelope - factory := withAuthoringBridge(func(_ context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - injected := req.AgentOptions["env"].(map[string]any) - path, _ = injected[proto.AuthoringSocketEnv].(string) - if path == "" || injected["MODEL_KEY"] != "preserved" { - t.Fatal("missing per-run context") - } - events = out - return nil, nil - }, authoring.New(nil)) - out := make(chan proto.Envelope, 1) - _, err = factory(t.Context(), proto.PromptRequestPayload{RunID: "run", WorkspaceAuthoring: true, AgentOptions: map[string]any{"env": env}}, out) - if err != nil { - t.Fatal(err) - } - if _, exists := env[proto.AuthoringSocketEnv]; exists { - t.Fatal("mutated caller environment") - } - if _, err := os.Stat(path); err != nil { - t.Fatal(err) - } - done, _ := proto.NewEnvelope(proto.TypeDone, "run", proto.DonePayload{}) - events <- done - select { - case <-out: - case <-time.After(time.Second): - t.Fatal("terminal event blocked") - } - if conn, err := net.DialTimeout("unix", path, time.Second); err == nil { - _ = conn.Close() - t.Fatal("authoring remained available after done") - } - close(events) -} - -func TestAuthoringRegistryRequiresExplicitCapability(t *testing.T) { - root, err := os.MkdirTemp("/tmp", "pa-opt-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(root) - t.Setenv("OAC_RUNTIME_HOME", root) - for _, optIn := range []bool{false, true} { - reg := agent.NewRegistry() - called := false - stop := errors.New("controlled factory stop") - out := make(chan proto.Envelope, 1) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "engine", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{WorkspaceAuthoring: proto.CapabilityFromBool(optIn)})}, harnessconfig.Configuration{}, func(_ context.Context, req proto.PromptRequestPayload, events chan<- proto.Envelope) (agent.Session, error) { - called = true - env, _ := req.AgentOptions["env"].(map[string]any) - socket, _ := env[proto.AuthoringSocketEnv].(string) - if (socket != "") != optIn { - t.Fatal("authoring capability was not respected") - } - if optIn { - if _, err := os.Stat(socket); err != nil { - t.Fatal(err) - } - } else if events != out { - t.Fatal("non-product event channel was wrapped") - } - return nil, stop - }) - wrapped := authoringRegistry(reg, authoring.New(nil)) - factory, err := wrapped.Resolve("engine") - if err != nil { - t.Fatal(err) - } - _, err = factory(t.Context(), proto.PromptRequestPayload{RunID: "run", WorkspaceAuthoring: true}, out) - if !called || !errors.Is(err, stop) { - t.Fatal("registered factory was not preserved", err) - } - } -} diff --git a/apps/daemon/internal/cli/capability_downloads.go b/apps/daemon/internal/cli/capability_downloads.go deleted file mode 100644 index c6def5f15..000000000 --- a/apps/daemon/internal/cli/capability_downloads.go +++ /dev/null @@ -1,85 +0,0 @@ -package cli - -import ( - "context" - "maps" - "net" - "net/url" - "slices" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Use the paired server address for loopback PG downloads: inside Compose, -// the public loopback address points to the runtime container itself. -func withCapabilityDownloads(factory agent.Factory, serverURL string) agent.Factory { - base, err := url.Parse(serverURL) - if err != nil || base.Host == "" || (base.Scheme != "http" && base.Scheme != "https") { - return factory - } - return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - req = capabilityDownloadRequest(req, base) - return factory(ctx, req, out) - } -} - -func capabilityDownloadURL(raw string, base *url.URL) string { - u, err := url.Parse(raw) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return raw - } - host := u.Hostname() - if !strings.EqualFold(host, "localhost") && !net.ParseIP(host).IsLoopback() { - return raw - } - const blobPath = "/internal/blobs/pg:" - start := strings.Index(u.Path, blobPath) - if start < 0 { - return raw - } - u.Scheme, u.Host, u.User = base.Scheme, base.Host, nil - u.Path = strings.TrimRight(base.Path, "/") + u.Path[start:] - u.RawPath = "" - return u.String() -} - -func capabilityDownloadRequest(req proto.PromptRequestPayload, base *url.URL) proto.PromptRequestPayload { - opts := maps.Clone(req.AgentOptions) - for _, key := range []string{"skills", "plugins"} { - items, ok := opts[key].([]any) - if !ok { - continue - } - items = slices.Clone(items) - for i, item := range items { - descriptor, ok := item.(map[string]any) - if !ok { - continue - } - raw, _ := descriptor["download_url"].(string) - if resolved := capabilityDownloadURL(raw, base); resolved != raw { - updated := maps.Clone(descriptor) - updated["download_url"] = resolved - items[i] = updated - } - } - opts[key] = items - } - req.AgentOptions = opts - return req -} - -// Executor setup applies the same immutable capability snapshot as ordinary execution. -func withExecutorCapabilities(factory agent.ExecutorFactory, serverURL string) agent.ExecutorFactory { - base, err := url.Parse(serverURL) - valid := err == nil && base.Host != "" && (base.Scheme == "http" || base.Scheme == "https") - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if valid { - req = capabilityDownloadRequest(req, base) - } - req = skillUploadRequest(req, serverURL) - return factory(ctx, req) - } -} diff --git a/apps/daemon/internal/cli/capability_downloads_test.go b/apps/daemon/internal/cli/capability_downloads_test.go deleted file mode 100644 index ecdcbc22f..000000000 --- a/apps/daemon/internal/cli/capability_downloads_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package cli - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestCapabilityDownloadUsesPairedServerOnlyForLoopbackPG(t *testing.T) { - base, _ := url.Parse("https://parsar.example.test/team") - for _, tc := range []struct{ raw, want string }{ - {"http://127.0.0.1:28080/internal/blobs/pg:id?token=a%2Bb", "https://parsar.example.test/team/internal/blobs/pg:id?token=a%2Bb"}, - {"http://localhost:28080/old/internal/blobs/pg:id?token=a", "https://parsar.example.test/team/internal/blobs/pg:id?token=a"}, - {"http://[::1]:28080/internal/blobs/pg:id?token=a", "https://parsar.example.test/team/internal/blobs/pg:id?token=a"}, - {"https://bucket.example.test/skill.zip?Signature=a", "https://bucket.example.test/skill.zip?Signature=a"}, - {"http://localhost:28080/other.zip", "http://localhost:28080/other.zip"}, - {"https://public.example.test/internal/blobs/pg:id?token=a", "https://public.example.test/internal/blobs/pg:id?token=a"}, - {"file:///internal/blobs/pg:id", "file:///internal/blobs/pg:id"}, - } { - if got := capabilityDownloadURL(tc.raw, base); got != tc.want { - t.Errorf("%s: got %s, want %s", tc.raw, got, tc.want) - } - } -} - -func TestCapabilityDownloadsInstallZIPThroughPairedServer(t *testing.T) { - var archive bytes.Buffer - w := zip.NewWriter(&archive) - for name, content := range map[string]string{ - "SKILL.md": "---\nname: qa-download\ndescription: Test downloaded context\n---\nRead references/policy.md.\n", - "references/policy.md": "Collect the laptop on the third working day.\n", - } { - file, err := w.Create(name) - if err != nil { - t.Fatal(err) - } - if _, err := file.Write([]byte(content)); err != nil { - t.Fatal(err) - } - } - if err := w.Close(); err != nil { - t.Fatal(err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/team/internal/blobs/pg:test" || r.URL.RawQuery != "token=synthetic%2Bsignature" { - t.Errorf("download request changed signed resource: %s", r.URL) - http.Error(w, "unexpected request", http.StatusForbidden) - return - } - _, _ = w.Write(archive.Bytes()) - })) - defer server.Close() - root := t.TempDir() - rawURL := "http://127.0.0.1:1/internal/blobs/pg:test?token=synthetic%2Bsignature" - descriptor := map[string]any{"name": "qa-download", "version": "1.0.0", "download_url": rawURL, "sha256": fmt.Sprintf("%x", sha256.Sum256(archive.Bytes()))} - opts := map[string]any{"skills": []any{descriptor}, "plugins": []any{descriptor}, "model": "preserved"} - factory := withCapabilityDownloads(func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - if req.RunID != "run-test" || req.AgentOptions["model"] != "preserved" { - t.Fatal("unrelated request fields changed") - } - plugin := req.AgentOptions["plugins"].([]any)[0].(map[string]any) - skill := req.AgentOptions["skills"].([]any)[0].(map[string]any) - if plugin["download_url"] != skill["download_url"] { - t.Fatal("plugin and skill routing differs") - } - result, err := managedskills.InstallManagedSkills(ctx, nil, root, req.AgentOptions["skills"]) - if err == nil && len(result.Warnings) > 0 { - err = fmt.Errorf("installation warnings: %v", result.Warnings) - } - return nil, err - }, server.URL+"/team") - if _, err := factory(context.Background(), proto.PromptRequestPayload{RunID: "run-test", AgentOptions: opts}, nil); err != nil { - t.Fatal(err) - } - content, err := os.ReadFile(filepath.Join(root, "qa-download", "references", "policy.md")) - if err != nil || string(content) != "Collect the laptop on the third working day.\n" { - t.Fatalf("installed reference: %q, %v", content, err) - } - if descriptor["download_url"] != rawURL { - t.Fatal("rewrote the original request options") - } -} - -func TestExecutorPreparationUsesCapabilitySnapshotTransform(t *testing.T) { - originalURL := "http://localhost:8080/internal/blobs/pg:skill" - descriptor := map[string]any{"download_url": originalURL} - source := proto.PromptRequestPayload{AgentOptions: map[string]any{"skills": []any{descriptor}}} - calls := 0 - factory := withExecutorCapabilities(func(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - calls++ - actual := req.AgentOptions["skills"].([]any)[0].(map[string]any)["download_url"] - if actual != "http://paired-core:8080/internal/blobs/pg:skill" { - t.Fatalf("executor capability URL = %v", actual) - } - return nil, nil - }, "http://paired-core:8080") - if _, err := factory(t.Context(), source); err != nil { - t.Fatal(err) - } - if calls != 1 || descriptor["download_url"] != originalURL { - t.Fatal("preparation changed the source snapshot or repeated setup") - } -} 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 7c4899315..283a34291 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -13,7 +13,6 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" @@ -72,8 +71,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { run := func(index int, prompt, resume string, callFunction, cancelOnText bool) execution { t.Helper() reg := agent.NewRegistry() - registerAgentKinds(reg, discovery, "https://product.invalid") - reg = authoringRegistry(reg, authoring.New(nil)) + registerAgentKinds(reg, discovery) sender := make(registeredSDKSender, 256) router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) if err != nil { @@ -88,14 +86,14 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { }() ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) defer cancel() - id := uuid.NewString() - request := proto.PromptRequestPayload{RunID: id, AgentKind: "claude_sdk", Input: proto.TextInput(prompt), AgentStateKey: "registered-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": nil}} + id, prepare := uuid.NewString(), uuid.NewString() + request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: "agents-api-registered-acceptance", AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "MiniMax-M3", "system_prompt": nil}} 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}`)}} } - handle := func(kind string, payload any) { + send := func(kind, envelopeID string, payload any) { t.Helper() - env, err := proto.NewEnvelope(kind, id, payload) + env, err := proto.NewEnvelope(kind, envelopeID, payload) if err != nil { t.Fatal(err) } @@ -103,7 +101,21 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { t.Fatal("registered router request failed", err) } } - handle(proto.TypePromptRequest, request) + handle := func(kind string, payload any) { send(kind, id, payload) } + send(proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: "registered-acceptance", Configuration: request}) + var ready proto.PreparationStatusPayload + for ready.State != "ready" { + var event proto.Envelope + select { + case event = <-sender: + case <-ctx.Done(): + t.Fatal("registered preparation timed out", ctx.Err()) + } + if event.Type != proto.TypePreparationStatus || event.ID != prepare || event.DecodePayload(&ready) != nil || ready.State != "preparing" && ready.State != "ready" { + t.Fatalf("registered preparation failed: %s", event.Payload) + } + } + send(proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: id, Input: proto.TextInput(prompt)}) proof := execution{} defer func() { data, _ := json.MarshalIndent(proof, "", " ") @@ -119,6 +131,13 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { case <-ctx.Done(): t.Fatal("registered execution timed out", ctx.Err()) } + if event.Type == proto.TypePreparationStatus && event.ID == prepare { + var status proto.PreparationStatusPayload + if event.DecodePayload(&status) != nil || status.RunID != id || status.State != "starting" && status.State != "started" { + t.Fatalf("registered start failed: %s", event.Payload) + } + continue + } if event.ID != id { t.Fatal("event identity changed") } @@ -182,7 +201,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Outcome.Content, nonce) { t.Fatal("registered cold continuation lost identity or history") } - data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration/authoring registry -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery[0].runtime.Info, "entrypoint": entrypoint, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") + data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery[0].runtime.Info, "entrypoint": entrypoint, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/companion_path_test.go b/apps/daemon/internal/cli/companion_path_test.go deleted file mode 100644 index 4d8b560fa..000000000 --- a/apps/daemon/internal/cli/companion_path_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" -) - -func TestCompanionCLIPath(t *testing.T) { - for _, tc := range []struct { - name string - mode os.FileMode - want bool - }{ - {"executable", 0o700, true}, - {"download awaiting review", 0o600, false}, - {"missing", 0, false}, - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - if tc.mode != 0 { - if err := os.WriteFile(filepath.Join(dir, "parsar"), []byte("binary"), tc.mode); err != nil { - t.Fatal(err) - } - } - env := map[string]any{"PATH": "/existing/tools", "OTHER": "retained"} - addCompanionCLIPath(env, dir) - want := "/existing/tools" - if tc.want { - want = dir + string(os.PathListSeparator) + want - } - if env["PATH"] != want || env["OTHER"] != "retained" { - t.Fatalf("unexpected child environment: %v", env) - } - }) - } -} diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index c9e65aef0..43583ce79 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -7,12 +7,10 @@ import ( "log/slog" "os" "path/filepath" - "strings" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" @@ -28,36 +26,25 @@ const ( // Allow the native process grace period and subsequent owner/pipe cleanup. stopTimeout = 10 * time.Second - - connectInlineURLEnv = "OAC_RUNTIME_DAEMON_CONNECT_URL" - connectInlineTokenEnv = "OAC_RUNTIME_DAEMON_CONNECT_TOKEN" - connectInlineDeviceNameEnv = "OAC_RUNTIME_DAEMON_CONNECT_DEVICE_NAME" ) // 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. // -// `connect --url --token` folds one-shot pairing into the connect step: -// the daemon consumes the pairing token, persists the returned runner -// credential to auth.json, and connects. Subsequent `connect -b` -// invocations reload the persisted profile. +// 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 -// re-enters runConnect via BackgroundSentinelEnv. When --token is -// supplied, the parent forks before pairing so the one-shot token is -// consumed by the long-lived child. Inline pairing flags are scrubbed -// from child argv and passed via environment to keep the token out of -// process listings. +// re-enters runConnect via BackgroundSentinelEnv. func runConnect(ctx *runContext, args []string) error { fs := newFlagSet("connect") var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name for paired credentials 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") - serverURL = fs.String("url", "", "Core server base URL; with --token, pair inline before connecting") - token = fs.String("token", "", "pairing token; with --url, connect consumes it without writing auth.json") - deviceName = fs.String("device-name", "", "human label for inline pairing (defaults to hostname)") remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") bootstrapFile = fs.String("bootstrap-file", "", "absolute path to Provider-to-Runtime connection JSON") @@ -75,21 +62,13 @@ func runConnect(ctx *runContext, args []string) error { } else if !errors.Is(err, os.ErrNotExist) { return errors.New("connect: cannot inspect native installation; use oac-daemon start") } - // Hydrate inline pairing inputs from env in BOTH parent and the - // re-execed background child. Server-spawned sandboxes pass the - // token via OAC_RUNTIME_DAEMON_CONNECT_TOKEN/URL env rather than --url - // /--token flags; without this hydration before the pre-fork - // auth.json check below, the parent would take the "rely on - // auth.json" branch and bail with "not paired". Idempotent — - // fills only empty flags and unsets the env after consuming. - loadInlineConnectEnv(serverURL, token, deviceName) if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } var bootstrapped *auth.Profile if *bootstrapFile != "" { - if *serverURL != "" || *token != "" || *deviceName != "" || *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { - return errors.New("connect: bootstrap input cannot be combined with enrollment or pairing options") + if *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { + return errors.New("connect: bootstrap input cannot be combined with enrollment options") } bootstrapped, err = bootstrapProfile(*bootstrapFile, ctx) if err != nil { @@ -97,140 +76,51 @@ func runConnect(ctx *runContext, args []string) error { } } if *remote != "" || *environment != "" || *credentialFile != "" { - if *serverURL != "" || *token != "" || *deviceName != "" || fs.NArg() != 0 { - return errors.New("connect: Environment enrollment cannot use pairing options or positional arguments") + if fs.NArg() != 0 { + return errors.New("connect: Environment enrollment cannot use positional arguments") } connectCtx, stop := daemonize.NotifyContext(context.Background()) defer stop() return runEnvironmentConnect(connectCtx, ctx, *profile, *background, *remote, *environment, *credentialFile) } - inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" - if inlinePair { - if strings.TrimSpace(*serverURL) == "" { - return fmt.Errorf("connect: --url is required when --token is supplied") - } - if strings.TrimSpace(*token) == "" { - return fmt.Errorf("connect: --token is required when --url is supplied") - } - } - // -b mode: parent forks, child re-enters with sentinel env set - // and skips this branch. Fork before inline pairing so the - // one-shot token is consumed by the child that owns the WS loop. + // and skips this branch. if *background && !daemonize.IsBackgroundChild() { // Validate auth.json exists before forking so the error // surfaces in the user's terminal instead of the background // child's log. - if !inlinePair && bootstrapped == nil { + if bootstrapped == nil { if _, err := auth.Load(*profile); err != nil { return fmt.Errorf("connect: %w", err) } } - argv := os.Args - extraEnv := []string(nil) - if inlinePair { - argv = scrubInlineConnectArgs(os.Args) - extraEnv = inlineConnectEnv(*serverURL, *token, *deviceName) - } - return spawnBackground(context.Background(), ctx, *profile, argv, extraEnv) + return spawnBackground(context.Background(), ctx, *profile, os.Args) } initializeRuntimeObservations(ctx) discover := func(parent context.Context) (agentCLIDiscovery, error) { return preflightAgentCLIs(parent, ctx, *profile) } - // Validate before consuming a one-shot pairing token, then reuse that result. - if inlinePair { - discovery, err := discover(context.Background()) - if err != nil { - return err - } - discover = func(context.Context) (agentCLIDiscovery, error) { return discovery, nil } - } var prof auth.Profile if bootstrapped != nil { prof = *bootstrapped } else { - prof, err = resolveConnectProfile(*profile, *serverURL, *token, *deviceName) + prof, err = auth.Load(*profile) if err != nil { - return err + return fmt.Errorf("connect: %w", err) } } return mainLoopRemoteWithDiscovery(context.Background(), ctx, *profile, prof, "", discover) } -func loadInlineConnectEnv(serverURL, token, deviceName *string) { - if strings.TrimSpace(*serverURL) == "" { - *serverURL = os.Getenv(connectInlineURLEnv) - } - if strings.TrimSpace(*token) == "" { - *token = os.Getenv(connectInlineTokenEnv) - } - if strings.TrimSpace(*deviceName) == "" { - *deviceName = os.Getenv(connectInlineDeviceNameEnv) - } - _ = os.Unsetenv(connectInlineURLEnv) - _ = os.Unsetenv(connectInlineTokenEnv) - _ = os.Unsetenv(connectInlineDeviceNameEnv) -} - -func inlineConnectEnv(serverURL, token, deviceName string) []string { - out := []string{ - connectInlineURLEnv + "=" + serverURL, - connectInlineTokenEnv + "=" + token, - } - if strings.TrimSpace(deviceName) != "" { - out = append(out, connectInlineDeviceNameEnv+"="+deviceName) - } - return out -} - -func scrubInlineConnectArgs(argv []string) []string { - out := make([]string, 0, len(argv)) - for i := 0; i < len(argv); i++ { - arg := argv[i] - switch { - case arg == "--url" || arg == "--token" || arg == "--device-name": - i++ - continue - case strings.HasPrefix(arg, "--url=") || strings.HasPrefix(arg, "--token=") || strings.HasPrefix(arg, "--device-name="): - continue - default: - out = append(out, arg) - } - } - return out -} - -func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.Profile, error) { - if strings.TrimSpace(serverURL) == "" && strings.TrimSpace(token) == "" { - prof, err := auth.Load(profile) - if err != nil { - return auth.Profile{}, fmt.Errorf("connect: %w", err) - } - return prof, nil - } - - pairCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - prof, _, err := pairProfile(pairCtx, serverURL, token, deviceName) - if err != nil { - return auth.Profile{}, fmt.Errorf("connect: pair with server: %w", err) - } - if err := auth.Save(profile, prof); err != nil { - return auth.Profile{}, fmt.Errorf("connect: save auth profile: %w", err) - } - return prof, nil -} - // spawnBackground forks the daemon into the background. Parent // 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, argv []string, extraEnv []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) @@ -259,7 +149,7 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string, argv [ } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, daemonize.ErrStaleOrCorrupt) { return fmt.Errorf("connect: check pidfile: %w", err) } - // Stale pidfile → remove so WritePIDFile starts clean. + // Stale pidfile → remove so Spawn starts clean. _ = daemonize.RemovePIDFile(pidPath) if err := daemonize.EnsureLogFile(logPath); err != nil { @@ -270,9 +160,8 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string, argv [ return err } pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ - LogPath: logPath, - PIDPath: pidPath, - ExtraEnv: extraEnv, + LogPath: logPath, + PIDPath: pidPath, }) if err != nil { return fmt.Errorf("connect: spawn background: %w", err) @@ -341,7 +230,7 @@ func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile obslog.Bg().Info("bootstrap ok", "device_id", boot.DeviceID, "ws_url", wsURL, "heartbeat_interval", boot.HeartbeatInterval()) registry := agent.NewRegistry() - registerAgentKinds(registry, agentCLIs, prof.ServerURL) + registerAgentKinds(registry, agentCLIs) control, err := newSuspendControl() if err != nil { @@ -401,7 +290,7 @@ func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile 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) } @@ -426,7 +315,7 @@ func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile // 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. @@ -443,8 +332,6 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R if err != nil { return err } - bridge := authoring.New(conn) - registry = authoringRegistry(registry, bridge) router, err := dispatch.New(dispatch.Config{ Registry: registry, Sender: conn, @@ -487,10 +374,6 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R obslog.Bg().Warn("pumpConn: recvCh closed", "err", conn.Err()) return conn.Err() } - if env.Type == proto.TypeAuthoringResponse { - bridge.Deliver(env) - continue - } obslog.Bg().Info("pumpConn: received envelope, calling router.Handle", "type", env.Type, "id", env.ID) if err := router.Handle(parentCtx, env); err != nil { obslog.Bg().Error("router.Handle failed", "type", env.Type, "id", env.ID, "err", err) diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go index bc7e045f4..27705a1c8 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, rc *runContext) (*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 ace627f1c..fbfb704ab 100644 --- a/apps/daemon/internal/cli/connect_bootstrap_test.go +++ b/apps/daemon/internal/cli/connect_bootstrap_test.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "io" "os" "path/filepath" @@ -10,12 +11,24 @@ 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/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) prior := auth.Profile{ServerURL: "https://other.example/api/v1", RuntimeID: "retained", RunnerCredential: "retained-secret"} - if err := auth.Save("default", prior); err != nil { + profileDir, err := paths.ProfileDir("default") + if err != nil { + t.Fatal(err) + } + if err = runtimefs.EnsurePrivateDir(profileDir); err != nil { + t.Fatal(err) + } + 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", Harness: "codex"} @@ -59,13 +72,12 @@ func TestConnectBootstrapRejectsOtherCredentialSourcesBeforeSideEffects(t *testi t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) ctx := &runContext{stdin: strings.NewReader(""), stdout: io.Discard, stderr: io.Discard} for _, args := range [][]string{ - {"--url", "https://core.example", "--token", "private-token"}, {"--remote", "wss://core.example/api/v1/agent-daemon/ws"}, {"--credential-file", "/credential.json"}, {"--environment-id", "foreign"}, {"unexpected"}, } { err := runConnect(ctx, append([]string{"--bootstrap-file", "/not-present"}, args...)) - if err == nil || !strings.Contains(err.Error(), "cannot be combined") || strings.Contains(err.Error(), "private-token") { + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { t.Fatal("mixed startup source accepted", err) } } diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index e8ef2d21e..ba6192874 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -115,10 +115,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { } owner := &cleanupExecutor{retry: make(chan struct{}), confirm: make(chan struct{})} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, - harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("unexpected legacy factory") - }) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}) var factories atomic.Int32 registry.RegisterExecutor("cleanup", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { factories.Add(1) @@ -148,7 +145,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { } defer peer.Close() env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", - Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", StrictResume: true, DisableExecutionEnvironment: true}}) + Configuration: 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 ac6f9b82d..5e5f1c592 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -162,7 +162,7 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(parent, rc, profile, os.Args, nil) + return spawnBackground(parent, rc, profile, os.Args) } prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential} return rejected(mainLoopRemote(parent, rc, profile, prof, remote)) diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index 9fa651798..b661092e0 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -95,17 +95,6 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti return nil } -// Neither selecting private state nor selecting its parent grants workspace access. -func environmentPathsOverlap(first, second string) bool { - for _, pair := range [][2]string{{first, second}, {second, first}} { - relative, err := filepath.Rel(pair[0], pair[1]) - if err == nil && (relative == "." || filepath.IsLocal(relative)) { - return true - } - } - return false -} - func saveEnvironmentBinding(root string, want environmentBinding) error { // Store the binding with the other daemon state. dir := filepath.Join(root, "daemon") diff --git a/apps/daemon/internal/cli/connect_environment_test.go b/apps/daemon/internal/cli/connect_environment_test.go index dc9bd3811..8d398b59b 100644 --- a/apps/daemon/internal/cli/connect_environment_test.go +++ b/apps/daemon/internal/cli/connect_environment_test.go @@ -243,17 +243,6 @@ func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { } } -func TestEnvironmentConnectRejectsPairing(t *testing.T) { - t.Setenv(connectInlineURLEnv, "") - t.Setenv(connectInlineTokenEnv, "") - t.Setenv(connectInlineDeviceNameEnv, "") - rc := &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}} - err := runConnect(rc, []string{"--remote", "wss://core/api/v1/agent-daemon/ws", "--environment-id", uuid.NewString(), "--credential-file", "/unused", "--token", "private-pairing-canary"}) - if err == nil || strings.Contains(err.Error(), "private-pairing-canary") { - t.Fatal("pairing accepted or leaked") - } -} - func TestEnvironmentEnrollmentAcceptsPhysicalWorkspaceSelection(t *testing.T) { environment := uuid.NewString() want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/srv/runtime/workspace"} diff --git a/apps/daemon/internal/cli/connect_startup_test.go b/apps/daemon/internal/cli/connect_startup_test.go index 6c26a21f9..bf9ee9530 100644 --- a/apps/daemon/internal/cli/connect_startup_test.go +++ b/apps/daemon/internal/cli/connect_startup_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -214,9 +213,13 @@ func TestMainLoopOverlapsPreflightWithoutEarlyRegistration(t *testing.T) { } defer close(completed) info.Available = failure != "discovery" && ctx.Err() == nil - return &agent.Runtime{Info: info, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("unexpected session") - }} + runtime := &agent.Runtime{Info: info} + if info.Available { + runtime.Executor = func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + return nil, errors.New("unexpected executor") + } + } + return runtime } harnessDeclarations = []agent.Declaration{declaration} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -268,49 +271,19 @@ func TestMainLoopOverlapsPreflightWithoutEarlyRegistration(t *testing.T) { } } -func TestInlinePairDiscoveryBeforeTokenAndOnlyOnce(t *testing.T) { - for _, available := range []bool{false, true} { - t.Run(fmt.Sprint(available), func(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - t.Setenv("OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE", "") - original := harnessDeclarations - defer func() { harnessDeclarations = original }() - var probes, pairs, boots atomic.Int32 - declaration := original[0] - declaration.Discover = func(_ context.Context, _ agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { - probes.Add(1) - info.Available = available - return &agent.Runtime{Info: info} - } - harnessDeclarations = []agent.Declaration{declaration} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v1/runtimes/pair": - if probes.Load() != 1 { - t.Error("pair before discovery") - } - pairs.Add(1) - _ = json.NewEncoder(w).Encode(pairResponse{Runtime: pairRuntime{ID: "device"}, RunnerCredential: "fixture"}) - case "/agent-daemon/bootstrap": - boots.Add(1) - http.Error(w, "end test", http.StatusUnauthorized) - default: - t.Errorf("unexpected request: %s", r.URL.Path) - http.NotFound(w, r) - } - })) - defer server.Close() - err := runConnect(&runContext{stdout: io.Discard, stderr: io.Discard}, []string{"--url", server.URL, "--token", "fixture"}) - if err == nil { - t.Fatal("expected failure") - } - expected := int32(0) - if available { - expected = 1 - } - if probes.Load() != 1 || pairs.Load() != expected || boots.Load() != expected { - t.Fatalf("probes=%d pairs=%d bootstrap=%d", probes.Load(), pairs.Load(), boots.Load()) - } - }) +func TestConnectRejectsRemovedPairingFlagsBeforeDiscovery(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + original := harnessDeclarations + defer func() { harnessDeclarations = original }() + declaration := original[0] + declaration.Discover = func(context.Context, agent.DiscoveryOptions, proto.SupportedAgentKind) *agent.Runtime { + t.Fatal("removed pairing options reached discovery") + return nil + } + harnessDeclarations = []agent.Declaration{declaration} + for _, args := range [][]string{{"--url", "https://fixture.invalid"}, {"--token", "fixture"}, {"--device-name", "fixture"}} { + if err := runConnect(&runContext{stdout: io.Discard, stderr: io.Discard}, args); err == nil { + t.Fatalf("removed pairing flags accepted: %s", args[0]) + } } } diff --git a/apps/daemon/internal/cli/connect_suspend.go b/apps/daemon/internal/cli/connect_suspend.go index 5050d6e06..b1146b94d 100644 --- a/apps/daemon/internal/cli/connect_suspend.go +++ b/apps/daemon/internal/cli/connect_suspend.go @@ -7,7 +7,6 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" @@ -34,7 +33,6 @@ func (s *reconnectSender) replace(conn *transport.Conn) { s.mu.Lock(); s.conn = type suspendedRouter struct { router *dispatch.Router - bridge *authoring.Bridge sender *reconnectSender registry *agent.Registry local *localworkspace.Binding @@ -46,13 +44,11 @@ func newSuspendedRouter(conn *transport.Conn, registry *agent.Registry) (*suspen return nil, err } sender := &reconnectSender{conn: conn} - bridge := authoring.New(sender) - wrapped := authoringRegistry(registry, bridge) - router, err := dispatch.New(dispatch.Config{Registry: wrapped, Sender: sender, Log: obslog.Bg(), LocalWorkspace: local}) + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, Log: obslog.Bg(), LocalWorkspace: local}) if err != nil { return nil, err } - return &suspendedRouter{router: router, bridge: bridge, sender: sender, registry: wrapped, local: local}, nil + return &suspendedRouter{router: router, sender: sender, registry: registry, local: local}, nil } func (s *suspendedRouter) shutdown() { @@ -177,10 +173,6 @@ func (s *suspendedRouter) pump(ctx context.Context, conn *transport.Conn, boot * if !ok { return nil, conn.Err() } - if env.Type == proto.TypeAuthoringResponse { - s.bridge.Deliver(env) - continue - } if env.Type == proto.TypeEnvironmentResume { request := rejectedResumeRequest(env) code := "not_suspended" diff --git a/apps/daemon/internal/cli/connect_test.go b/apps/daemon/internal/cli/connect_test.go deleted file mode 100644 index 8a5daeb03..000000000 --- a/apps/daemon/internal/cli/connect_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package cli - -import ( - "os" - "reflect" - "strings" - "testing" -) - -func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { - got := scrubInlineConnectArgs([]string{ - "oac-daemon", "connect", - "--url", "https://core.example.com", - "--token=rtk_secret", - "--device-name", "dev-1", - "-b", - "--profile", "sandbox", - }) - want := []string{"oac-daemon", "connect", "-b", "--profile", "sandbox"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("scrubInlineConnectArgs() = %#v, want %#v", got, want) - } -} - -func TestLoadInlineConnectEnvFillsMissingValuesAndUnsets(t *testing.T) { - t.Setenv(connectInlineURLEnv, "https://core.example.com") - t.Setenv(connectInlineTokenEnv, "rtk_secret") - t.Setenv(connectInlineDeviceNameEnv, "dev-1") - - serverURL, token, deviceName := "", "", "" - loadInlineConnectEnv(&serverURL, &token, &deviceName) - - if serverURL != "https://core.example.com" || token != "rtk_secret" || deviceName != "dev-1" { - t.Fatalf("loaded values = (%q, %q, %q)", serverURL, token, deviceName) - } - if got := inlineConnectEnvValue(connectInlineTokenEnv); got != "" { - t.Fatalf("%s still set to %q", connectInlineTokenEnv, got) - } -} - -func inlineConnectEnvValue(key string) string { return os.Getenv(key) } - -// Regression: pre-fork auth.json check used to run BEFORE env-to-flag -// hydration, so sandboxes passing the token via env bailed with -// "not paired". loadInlineConnectEnv now runs first. -func TestLoadInlineConnectEnvHydratesParentProcessFlags(t *testing.T) { - t.Setenv(connectInlineURLEnv, "https://core.example.com") - t.Setenv(connectInlineTokenEnv, "rtk_secret") - - serverURL, token, deviceName := "", "", "" - - loadInlineConnectEnv(&serverURL, &token, &deviceName) - - // Same predicate runConnect uses to decide whether to skip the - // pre-fork auth.json check. - inlinePair := strings.TrimSpace(serverURL) != "" || strings.TrimSpace(token) != "" - if !inlinePair { - t.Fatalf("inlinePair=false after env hydration; serverURL=%q token=%q", serverURL, token) - } -} diff --git a/apps/daemon/internal/cli/native_discovery_test.go b/apps/daemon/internal/cli/native_discovery_test.go index 6bc38f72d..29776bcde 100644 --- a/apps/daemon/internal/cli/native_discovery_test.go +++ b/apps/daemon/internal/cli/native_discovery_test.go @@ -21,9 +21,7 @@ func TestDiscoveryAndRegistration(t *testing.T) { called = append(called, info.Kind) info.Available = true info.Version = "test" - return &agent.Runtime{Info: info, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("test") - }} + return &agent.Runtime{Info: info} } } rc := &runContext{stdout: io.Discard, stderr: io.Discard} @@ -40,11 +38,12 @@ func TestDiscoveryAndRegistration(t *testing.T) { t.Fatalf("probes %v, want %v", called, expected) } registry := agent.NewRegistry() - registerAgentKinds(registry, discovery, "") - if len(registry.Kinds()) != len(expected) { - t.Fatal(registry.Kinds()) + registerAgentKinds(registry, discovery) + kinds := registry.SupportedAgentKinds() + if len(kinds) != len(expected) { + t.Fatal(kinds) } - for _, info := range registry.SupportedAgentKinds() { + for _, info := range kinds { if !info.Available || info.Version != "test" { t.Fatal(info) } diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index 0deb2f5b2..2a8453d1f 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -257,10 +257,9 @@ func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { for _, connection := range [][]string{ nil, {"--remote", "ws://127.0.0.1:1/api/v1/agent-daemon/ws"}, - {"--url", "http://127.0.0.1:1", "--token", "private-test-token"}, } { err := runConnect(rc, connection) - if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") || strings.Contains(err.Error(), "private-test-token") { + if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") { t.Fatal("connect bypassed native installation validation", err) } } diff --git a/apps/daemon/internal/cli/pair.go b/apps/daemon/internal/cli/pair.go deleted file mode 100644 index c5b65cee4..000000000 --- a/apps/daemon/internal/cli/pair.go +++ /dev/null @@ -1,145 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" -) - -func pairProfile(ctx context.Context, serverURL, token, deviceName string) (auth.Profile, *pairResponse, error) { - base, err := normalizeServerURL(serverURL) - if err != nil { - return auth.Profile{}, nil, err - } - - host, err := os.Hostname() - if err != nil { - // Stripped-down sandboxes can fail os.Hostname; "unknown" - // keeps the request well-formed (it's only a label). - host = "unknown" - } - if strings.TrimSpace(deviceName) == "" { - deviceName = host - } - - // Generate a fresh X25519 keypair before pairing. Public half is - // persisted server-side under runtimes.config.runner_public_key so - // SealForRuntime can encrypt payloads to this daemon; private half - // stays in auth.Profile (0o600) and is required to OpenSeal on - // receive. Every successful pair binds a brand-new pair. - pubB64, privB64, err := runtimecrypto.GenerateRuntimeKeypair() - if err != nil { - return auth.Profile{}, nil, fmt.Errorf("generate runner keypair: %w", err) - } - - pair, err := pairWithServer(ctx, base, pairRequest{ - PairingToken: token, - Hostname: host, - Version: Version, - RunnerPublicKey: pubB64, - }) - if err != nil { - return auth.Profile{}, nil, err - } - - prof := auth.Profile{ - ServerURL: base, - RuntimeID: pair.Runtime.ID, - RunnerCredential: pair.RunnerCredential, - DeviceName: deviceName, - Hostname: host, - PairedAt: time.Now().UTC(), - RunnerPublicKey: pubB64, - RunnerPrivateKey: privB64, - } - return prof, pair, nil -} - -// pairRequest mirrors the wire shape of server/internal/api/runtime. -// Re-declared here so the daemon doesn't import a server-internal -// package — keeps the wire schema as the only coupling. -type pairRequest struct { - PairingToken string `json:"pairing_token"` - Hostname string `json:"hostname"` - Version string `json:"version"` - RunnerPublicKey string `json:"runner_public_key,omitempty"` -} - -type pairRuntime struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Liveness string `json:"liveness"` -} - -type pairResponse struct { - Runtime pairRuntime `json:"runtime"` - RunnerCredential string `json:"runner_credential"` -} - -// pairWithServer issues POST /api/v1/runtimes/pair. Returns the parsed -// response on success; on non-2xx, returns an error containing the -// server's error code + message when present. -func pairWithServer(ctx context.Context, base string, req pairRequest) (*pairResponse, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal pair request: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/v1/runtimes/pair", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("User-Agent", "oac-daemon/"+Version) - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("post: %w", err) - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(respBody))) - } - var out pairResponse - if err := json.Unmarshal(respBody, &out); err != nil { - return nil, fmt.Errorf("decode pair response: %w", err) - } - if out.Runtime.ID == "" || out.RunnerCredential == "" { - return nil, fmt.Errorf("pair response missing runtime.id or runner_credential") - } - if out.Runtime.Type != "" && out.Runtime.Type != "agent_daemon" { - // Refuse rather than take over the wrong runtime row if the - // user pasted a non-agent_daemon pairing token by accident. - return nil, fmt.Errorf("pair response runtime.type=%q, expected agent_daemon (was the token issued under the Agent Daemon tab?)", out.Runtime.Type) - } - return &out, nil -} - -// normalizeServerURL rejects junk inputs and strips a trailing slash so -// concatenating "/api/v1/..." paths never produces "//". -func normalizeServerURL(raw string) (string, error) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return "", fmt.Errorf("parse --url: %w", err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return "", fmt.Errorf("--url must use http or https (got %q)", u.Scheme) - } - if u.Host == "" { - return "", fmt.Errorf("--url is missing a host") - } - u.Path = strings.TrimRight(u.Path, "/") - u.RawQuery = "" - u.Fragment = "" - return u.String(), nil -} diff --git a/apps/daemon/internal/cli/pair_test.go b/apps/daemon/internal/cli/pair_test.go deleted file mode 100644 index 620b5ed67..000000000 --- a/apps/daemon/internal/cli/pair_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" -) - -// Regression for the "runner_public_key required" pair failure: the -// daemon used to never populate that field on the wire, so server-side -// pairing rejected with HTTP 400. Asserts the request carries a -// base64(stdEncoding) X25519 pubkey AND the resulting Profile retains -// the matching privkey for later OpenSeal. -func TestPairProfileGeneratesAndSendsRunnerPublicKey(t *testing.T) { - var sentPubKey string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/runtimes/pair" { - http.Error(w, "unexpected path", http.StatusNotFound) - return - } - var body pairRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "bad json", http.StatusBadRequest) - return - } - sentPubKey = body.RunnerPublicKey - _ = json.NewEncoder(w).Encode(pairResponse{ - Runtime: pairRuntime{ - ID: "rt_test_123", - Type: "agent_daemon", - Name: "test-device", - }, - RunnerCredential: "rc_test_secret", - }) - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - prof, _, err := pairProfile(ctx, srv.URL, "pairing-token", "test-device") - if err != nil { - t.Fatalf("pairProfile: %v", err) - } - - // Server must have received a non-empty key. - if sentPubKey == "" { - t.Fatal("server did not receive runner_public_key on the wire") - } - // And it must be a valid X25519 pubkey. - if _, err := runtimecrypto.DecodeKey(sentPubKey); err != nil { - t.Fatalf("server received invalid pubkey %q: %v", sentPubKey, err) - } - - // Profile must carry both halves. - if prof.RunnerPublicKey != sentPubKey { - t.Errorf("Profile.RunnerPublicKey = %q, want sent %q", prof.RunnerPublicKey, sentPubKey) - } - if prof.RunnerPrivateKey == "" { - t.Fatal("Profile.RunnerPrivateKey is empty") - } - if _, err := runtimecrypto.DecodeKey(prof.RunnerPrivateKey); err != nil { - t.Errorf("Profile.RunnerPrivateKey is not a valid X25519 key: %v", err) - } - - // Sanity-check the rest of the Profile so a future refactor that - // drops one of these fields fails loudly. - if prof.RuntimeID != "rt_test_123" { - t.Errorf("RuntimeID = %q, want rt_test_123", prof.RuntimeID) - } - if prof.RunnerCredential != "rc_test_secret" { - t.Errorf("RunnerCredential = %q, want rc_test_secret", prof.RunnerCredential) - } -} - -// Pair-error pass-through must still surface a 400 when the server -// fails the pair for any reason (reused token, device limit, etc.). -func TestPairProfileSurfacesServerPairFailure(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"pair_failed","message":"token reused"}`)) - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _, _, err := pairProfile(ctx, srv.URL, "pairing-token", "test-device") - if err == nil { - t.Fatal("pairProfile succeeded against a 400 server response, want error") - } -} diff --git a/apps/daemon/internal/cli/preparation_test.go b/apps/daemon/internal/cli/preparation_test.go deleted file mode 100644 index 2fdb8a189..000000000 --- a/apps/daemon/internal/cli/preparation_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package cli - -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" -) - -func TestPreparationRegistrationBypassesProductWrappers(t *testing.T) { - for _, supported := range []bool{false, true} { - reg := agent.NewRegistry() - info := proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(supported)})} - runtime := agent.Runtime{Info: info, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, nil - }} - if supported { - runtime.Preparation = func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return nil, nil } - runtime.WorkspaceReadPreparation = true - } - registerAgentKinds(reg, agentCLIDiscovery{{declaration: agent.Declaration{Info: info}, runtime: runtime}}, "http://unreachable.invalid") - - _, err := reg.ResolvePreparation("codex") - if (err == nil) != supported { - t.Fatal("unverified native version advertised preparation") - } - if !supported { - continue - } - stop := errors.New("controlled preparation stop") - reg.RegisterPreparation("codex", true, func(_ context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if req.RunID != "" || req.WorkspaceAuthoring || len(req.AgentOptions) != 0 { - t.Error("product wrapper injected preparation context") - } - return nil, stop - }) - wrapped := authoringRegistry(reg, authoring.New(nil)) - prepare, err := wrapped.ResolvePreparation("codex") - if err != nil { - t.Fatal(err) - } - if _, err := prepare(t.Context(), proto.PromptRequestPayload{AgentKind: "codex"}); !errors.Is(err, stop) { - t.Fatal("raw preparation was lost or wrapped", err) - } - for _, info := range wrapped.SupportedAgentKinds() { - if info.Kind == "codex" && (!info.Capabilities.Preparation.IsSupported() || !info.Capabilities.WorkspaceReadPreparation.IsSupported()) { - t.Fatal("real heartbeat registry lost preparation") - } - } - wrapped.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, stop - }) - if _, err := wrapped.ResolvePreparation("codex"); err == nil { - t.Fatal("factory replacement retained stale preparation") - } - } -} diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 18a94b421..cd77a7d2b 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -40,8 +40,8 @@ var commands = []command{ {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, {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: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, - {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, + {name: "connect", summary: "Open the reverse WebSocket and start serving prompts", run: runConnect}, + {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/skill_upload.go b/apps/daemon/internal/cli/skill_upload.go deleted file mode 100644 index cdb7ef279..000000000 --- a/apps/daemon/internal/cli/skill_upload.go +++ /dev/null @@ -1,44 +0,0 @@ -package cli - -import ( - "context" - "maps" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func withSkillUploadServer(factory agent.Factory, serverURL string) agent.Factory { - return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - req = skillUploadRequest(req, serverURL) - return factory(ctx, req, out) - } -} - -func addCompanionCLIPath(env map[string]any, dir string) { - info, err := os.Stat(filepath.Join(dir, "parsar")) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { - return - } - path, ok := env["PATH"].(string) - if !ok { - path = os.Getenv("PATH") - } - env["PATH"] = dir + string(os.PathListSeparator) + path -} - -func skillUploadRequest(req proto.PromptRequestPayload, serverURL string) proto.PromptRequestPayload { - env, _ := req.AgentOptions["env"].(map[string]any) - if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { - env = maps.Clone(env) - env["PARSAR_SERVER_URL"] = serverURL - if executable, err := os.Executable(); err == nil { - addCompanionCLIPath(env, filepath.Dir(executable)) - } - req.AgentOptions = maps.Clone(req.AgentOptions) - req.AgentOptions["env"] = env - } - return req -} diff --git a/apps/daemon/internal/cli/skill_upload_test.go b/apps/daemon/internal/cli/skill_upload_test.go deleted file mode 100644 index ef8fc3921..000000000 --- a/apps/daemon/internal/cli/skill_upload_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cli - -import ( - "context" - "os" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestSkillUploadUsesPairedAddressPerRequest(t *testing.T) { - t.Setenv("PARSAR_SERVER_URL", "unchanged-process-env") - env := map[string]any{"PARSAR_CAPABILITY_UPLOAD_TOKEN": "run-a", "PARSAR_SERVER_URL": "http://localhost:1234", "MODEL_KEY": "preserve"} - opts := map[string]any{"env": env, "model": "preserve"} - calls := 0 - factory := withSkillUploadServer(func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - calls++ - if calls == 1 { - got := req.AgentOptions["env"].(map[string]any) - if got["PARSAR_SERVER_URL"] != "http://parsar-server:8080" || got["PARSAR_CAPABILITY_UPLOAD_TOKEN"] != "run-a" || got["MODEL_KEY"] != "preserve" || req.AgentOptions["model"] != "preserve" { - t.Fatal("per-run context incorrect") - } - if _, exists := got["PARSAR_RUNNER_TOKEN"]; exists { - t.Fatal("exported device credential") - } - } else if len(req.AgentOptions) != 0 { - t.Fatal("previous run's context leaked") - } - return nil, nil - }, "http://parsar-server:8080") - if _, err := factory(t.Context(), proto.PromptRequestPayload{AgentOptions: opts}, nil); err != nil { - t.Fatal(err) - } - if _, err := factory(t.Context(), proto.PromptRequestPayload{}, nil); err != nil { - t.Fatal(err) - } - if env["PARSAR_SERVER_URL"] != "http://localhost:1234" || os.Getenv("PARSAR_SERVER_URL") != "unchanged-process-env" { - t.Fatal("mutated caller or process environment") - } -} diff --git a/apps/daemon/internal/cli/startup_observation.go b/apps/daemon/internal/cli/startup_observation.go index 6844f1282..70dc54330 100644 --- a/apps/daemon/internal/cli/startup_observation.go +++ b/apps/daemon/internal/cli/startup_observation.go @@ -20,7 +20,7 @@ func observeRuntimeStartup(ctx context.Context, stage string, started time.Time, status = "timeout" } } - obslog.Info(ctx, "runtime startup stage", "stage", stage, + obslog.Ctx(ctx).Info("runtime startup stage", "stage", stage, "duration_ms", float64(time.Since(started))/float64(time.Millisecond), "status", status) } diff --git a/apps/daemon/internal/cli/status.go b/apps/daemon/internal/cli/status.go index d02019d45..beb9e444d 100644 --- a/apps/daemon/internal/cli/status.go +++ b/apps/daemon/internal/cli/status.go @@ -32,7 +32,7 @@ func runStatus(ctx *runContext, args []string) error { prof, err := auth.Load(*profile) switch { case errors.Is(err, auth.ErrNotPaired): - fmt.Fprintln(ctx.stdout, "paired : no saved pairing profile; check Host connection in Core for a self-hosted Runtime") + 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, "paired : ERROR — %v\n", err) default: @@ -42,12 +42,6 @@ func runStatus(ctx *runContext, args []string) error { if prof.DeviceName != "" { fmt.Fprintf(ctx.stdout, "device_name : %s\n", prof.DeviceName) } - if prof.Hostname != "" { - fmt.Fprintf(ctx.stdout, "hostname : %s\n", prof.Hostname) - } - if !prof.PairedAt.IsZero() { - fmt.Fprintf(ctx.stdout, "paired_at : %s\n", prof.PairedAt.Format("2006-01-02 15:04:05 MST")) - } } // connect.pid existence is the cheap signal; the full liveness diff --git a/apps/daemon/internal/daemonize/fork.go b/apps/daemon/internal/daemonize/fork.go index 8803c7ee1..120e7dd75 100644 --- a/apps/daemon/internal/daemonize/fork.go +++ b/apps/daemon/internal/daemonize/fork.go @@ -42,10 +42,6 @@ type ReExecOptions struct { // child identity here before returning; existing files are replaced // atomically. PIDPath string - - // ExtraEnv is appended to the child's environment in addition to - // parent environ + BackgroundSentinelEnv. - ExtraEnv []string } // Spawn re-execs the current binary in the background. argv is the @@ -89,7 +85,6 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { env := append([]string(nil), os.Environ()...) env = append(env, BackgroundSentinelEnv+"=1") - env = append(env, opts.ExtraEnv...) cmd := exec.Command(exe, argv[1:]...) cmd.Env = env diff --git a/apps/daemon/internal/daemonize/fork_test.go b/apps/daemon/internal/daemonize/fork_test.go index 7ad546605..a8f4fc5a6 100644 --- a/apps/daemon/internal/daemonize/fork_test.go +++ b/apps/daemon/internal/daemonize/fork_test.go @@ -12,6 +12,7 @@ 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 @@ -19,9 +20,8 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { pid, err := Spawn( []string{"oac-daemon", "child-mode"}, ReExecOptions{ - LogPath: logPath, - PIDPath: pidPath, - ExtraEnv: []string{spawnTestChildEnv + "=1"}, + LogPath: logPath, + PIDPath: pidPath, }, ) if err != nil { diff --git a/apps/daemon/internal/daemonize/logfile.go b/apps/daemon/internal/daemonize/logfile.go index 80124e1fc..a74f0dcfd 100644 --- a/apps/daemon/internal/daemonize/logfile.go +++ b/apps/daemon/internal/daemonize/logfile.go @@ -1,14 +1,14 @@ package daemonize import ( - "bufio" "errors" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "io" "os" "path/filepath" "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) // TailOptions configures Tail. @@ -149,27 +149,6 @@ func EnsureLogFile(path string) error { return f.Close() } -// MustWriteLine appends one line to path with 0o600 mode, adding a -// trailing newline if missing. Returns errors despite the name — -// kept short because it's used in startup hot paths. -func MustWriteLine(path string, line string) error { - f, err := openPrivateLog(path) - if err != nil { - return err - } - defer f.Close() - bw := bufio.NewWriter(f) - if _, err := bw.WriteString(line); err != nil { - return err - } - if len(line) == 0 || line[len(line)-1] != '\n' { - if _, err := bw.WriteString("\n"); err != nil { - return err - } - } - return bw.Flush() -} - func openPrivateLog(path string) (*os.File, error) { root, err := os.OpenRoot(filepath.Dir(path)) if err != nil { diff --git a/apps/daemon/internal/daemonize/logfile_test.go b/apps/daemon/internal/daemonize/logfile_test.go index a13bdca48..5d1caeb6e 100644 --- a/apps/daemon/internal/daemonize/logfile_test.go +++ b/apps/daemon/internal/daemonize/logfile_test.go @@ -182,21 +182,6 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { } } -func TestMustWriteLineAppendsTrailingNewline(t *testing.T) { - dir := privateTempDir(t) - path := filepath.Join(dir, "ml.log") - if err := MustWriteLine(path, "no-newline"); err != nil { - t.Fatalf("MustWriteLine: %v", err) - } - if err := MustWriteLine(path, "has-newline\n"); err != nil { - t.Fatalf("MustWriteLine: %v", err) - } - body, _ := os.ReadFile(path) - if string(body) != "no-newline\nhas-newline\n" { - t.Errorf("body = %q", string(body)) - } -} - // safeBuf wraps bytes.Buffer with a mutex so concurrent reads/writes // during Tail's poll loop don't race. type safeBuf struct { diff --git a/apps/daemon/internal/daemonize/pidfile.go b/apps/daemon/internal/daemonize/pidfile.go index 2224c8542..b6ad008c1 100644 --- a/apps/daemon/internal/daemonize/pidfile.go +++ b/apps/daemon/internal/daemonize/pidfile.go @@ -19,15 +19,6 @@ type processIdentity struct { StopEvent string `json:"stop_event,omitempty"` } -func WritePIDFile(path string, pid int) error { - identity, err := identifyProcess(pid) - if err != nil { - return err - } - identity.StopEvent = os.Getenv(stopEventEnv) - return writeIdentity(path, identity) -} - func writeIdentity(path string, identity processIdentity) error { if path == "" { return errors.New("daemonize: process record path required") @@ -71,8 +62,6 @@ func ReadPIDFile(path string) (int, error) { return identity.PID, err } -func IsAlive(pid int) error { _, err := identifyProcess(pid); return err } - // StopPIDFile never removes ownership before the exact process has exited. // A cleanup timeout leaves the record available for observation and a later stop. func StopPIDFile(path string, timeout time.Duration) error { diff --git a/apps/daemon/internal/daemonize/pidfile_test.go b/apps/daemon/internal/daemonize/pidfile_test.go index f390a7837..5f83362d0 100644 --- a/apps/daemon/internal/daemonize/pidfile_test.go +++ b/apps/daemon/internal/daemonize/pidfile_test.go @@ -11,7 +11,11 @@ import ( func TestProcessRecordIdentity(t *testing.T) { path := filepath.Join(privateTempDir(t), "connect.pid") - if err := WritePIDFile(path, os.Getpid()); err != nil { + record, err := identifyProcess(os.Getpid()) + if err == nil { + err = writeIdentity(path, record) + } + if err != nil { t.Fatal(err) } pid, err := ReadPIDFile(path) @@ -34,7 +38,7 @@ func TestProcessRecordIdentity(t *testing.T) { if err = StopPIDFile(path, time.Second); !errors.Is(err, ErrStaleOrCorrupt) { t.Fatal("stale identity was accepted", err) } - if err = IsAlive(os.Getpid()); err != nil { + if _, err = identifyProcess(os.Getpid()); err != nil { t.Fatal("unrelated process was affected", err) } if _, err = os.Stat(path); err != nil { @@ -66,7 +70,8 @@ func TestProcessRecordRejectsOldAndMalformed(t *testing.T) { func TestStopTimeoutRetainsProcessRecord(t *testing.T) { dir := privateTempDir(t) path := filepath.Join(dir, "connect.pid") - pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path, ExtraEnv: []string{spawnTestChildEnv + "=ignore"}}) + t.Setenv(spawnTestChildEnv, "ignore") + pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/cancellation.go b/apps/daemon/internal/dispatch/cancellation.go index 5dffa4532..d255c6f1e 100644 --- a/apps/daemon/internal/dispatch/cancellation.go +++ b/apps/daemon/internal/dispatch/cancellation.go @@ -2,22 +2,10 @@ package dispatch import ( "context" - "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (r *Router) releaseCompletedSession(state *sessionState) error { - r.mu.Lock() - state.retain = false - r.mu.Unlock() - receiptErr := r.finishSteering(state) - err := state.session.Cancel(context.Background()) - state.ctxCancel() - return errors.Join(receiptErr, err) -} - func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) error { var request proto.PromptCancelPayload if err := env.DecodePayload(&request); err != nil { @@ -29,39 +17,18 @@ func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) err return ErrRouterClosed } state := r.sessions[env.ID] - if state != nil { - state.retain = false - } - var owner agent.Session - if state != nil && state.preparedHandoff != nil { - handoff := state.preparedHandoff - release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) - if request.DeliveryID != "" { - r.shutdownWG.Add(1) - go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) - } + if state == nil { r.mu.Unlock() - return nil + return r.sendCancellationAck(ctx, env, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"}) } - if state != nil && state.session != nil { - owner = state.session + handoff := state.preparedHandoff + release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) + if request.DeliveryID != "" { + r.shutdownWG.Add(1) + go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) } r.mu.Unlock() - ack := proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"} - if state != nil && owner == nil { - ack.ErrorCode = "not_ready" - } else if owner != nil { - if err := owner.Cancel(ctx); err != nil { - r.log.WarnContext(ctx, "session.Cancel failed", "run_id", env.ID, "err", err) - ack.ErrorCode = "cancel_failed" - } else { - ack.Applied, ack.ErrorCode = true, "" - outcome := owner.CancellationOutcome() - ack.Outcome = &outcome - } - state.ctxCancel() - } - return r.sendCancellationAck(ctx, env, ack) + return nil } func (r *Router) sendCancellationAck(ctx context.Context, env proto.Envelope, ack proto.InteractionDecisionAckPayload) error { diff --git a/apps/daemon/internal/dispatch/cancellation_test.go b/apps/daemon/internal/dispatch/cancellation_test.go index f4273d5ef..d43722800 100644 --- a/apps/daemon/internal/dispatch/cancellation_test.go +++ b/apps/daemon/internal/dispatch/cancellation_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -25,40 +23,41 @@ 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, 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{})} - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} - return sess, nil - }) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "release", proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + registerCancelReceiptKind(h, sess) + startRun(t, h.router, h.sender, "release", proto.PromptRequestPayload{AgentKind: "codex"}) sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) <-sess.entered - if len(h.sender.snapshot()) != 0 { + if len(h.sender.typesFor("release")) != 0 { t.Fatal("completion acknowledged before native writer was released") } close(sess.release) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "release completion") - frames := h.sender.snapshot() - if len(frames) != 1 || frames[0].Type != proto.TypeDone || sess.cancels() != 1 { + 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 (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 TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { observed := proto.DonePayload{Content: "partial output", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} for _, test := range []struct { @@ -76,47 +75,31 @@ func TestCancellationReceiptFollowsAdapterOutcome(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} - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} - return sess, nil - }) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + registerCancelReceiptKind(h, sess) + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "codex"}) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})); err != nil { t.Fatal(err) } - done := make(chan error, 1) - go func() { - done <- h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})) - }() <-sess.entered - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - t.Fatal("cancellation acknowledged before adapter returned") - } + if len(cancellationAcks(h.sender)) != 0 { + t.Fatal("cancellation acknowledged before adapter returned") } close(sess.release) - if err := <-done; err != nil { - t.Fatal(err) + 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) } - found := false - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - found = true - var ack proto.InteractionDecisionAckPayload - _ = env.DecodePayload(&ack) - 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) - } - } + ack := acks[0] + if ack.Applied != (test.err == nil) || ack.DeliveryID != "cancel-1" { + t.Fatalf("wrong receipt: %+v", ack) } - if !found { - t.Fatal("missing cancellation receipt") + 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) } }) } @@ -125,14 +108,13 @@ func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "legacy", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess sess.closeOutOnCancel = true if err := h.router.Handle(context.Background(), mustEnv(t, 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/capability_admission_test.go b/apps/daemon/internal/dispatch/capability_admission_test.go index 0a9fde120..7aaf78466 100644 --- a/apps/daemon/internal/dispatch/capability_admission_test.go +++ b/apps/daemon/internal/dispatch/capability_admission_test.go @@ -9,7 +9,6 @@ import ( "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" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementation(t *testing.T) { @@ -18,20 +17,18 @@ func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementatio h := newHarness(t) defer h.router.Shutdown(context.Background()) var calls atomic.Int32 - factory := func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + factory := sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { calls.Add(1) return fmt.Errorf("%w: fixture has no active input", agent.ErrUnsupportedOperation) }}, nil - } - info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilityFromBool(supported)})} - h.reg.RegisterKind(info, harnessconfig.Configuration{}, factory) - if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "fixture"})); err != nil { - t.Fatal(err) - } + }) + info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilityFromBool(supported)})} + registerExecutorKind(h.reg, info, factory) + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "fixture"}) // A new registration cannot rewrite the already admitted owner's contract. info.Capabilities.Steering = proto.CapabilityFromBool(!supported) - h.reg.RegisterKind(info, harnessconfig.Configuration{}, factory) + registerExecutorKind(h.reg, info, factory) for range 2 { if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("hello")})); err != nil { t.Fatal(err) @@ -55,52 +52,3 @@ func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementatio }) } } - -func TestInteractionDeclarationPrecedesResponderMethods(t *testing.T) { - for _, supported := range []bool{false, true} { - for _, ask := range []bool{false, true} { - t.Run(fmt.Sprintf("supported=%v/ask=%v", supported, ask), func(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - var session *fakeSession - info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Permissions: proto.CapabilityFromBool(supported)})} - h.reg.RegisterKind(info, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - session = &fakeSession{out: out, closeOutOnCancel: true, submitErr: agent.ErrUnsupportedOperation, askErr: agent.ErrUnsupportedOperation} - return session, nil - }) - if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "fixture"})); err != nil { - t.Fatal(err) - } - event := mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "interaction", Tool: "fixture"}) - decision := mustEnv(t, proto.TypePermissionDecision, "interaction", proto.PermissionDecisionPayload{DeliveryID: "decision", Approved: true}) - if ask { - event = mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "interaction"}) - decision = mustEnv(t, proto.TypePromptForUserChoiceDecision, "interaction", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "decision"}) - } - session.out <- event - waitFor(t, func() bool { return len(h.sender.snapshot()) > 0 }, "interaction indexed") - if err := h.router.Handle(t.Context(), decision); err != nil { - t.Fatal(err) - } - code := "unsupported" - if supported { - code = "contract_violation" - } - assertDecisionAck(t, h.sender, "decision", false, code) - count := len(session.submissions()) - if ask { - session.askMu.Lock() - count = len(session.askCalls) - session.askMu.Unlock() - } - want := 0 - if supported { - want = 1 - } - if count != want { - t.Fatalf("native calls %d, want %d", count, want) - } - }) - } - } -} diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index 3b0cb4b91..74de20411 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -7,13 +7,19 @@ 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.ValidateAgentOptions(); err != nil { + return err + } 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 0e72f9020..c021ec3cb 100644 --- a/apps/daemon/internal/dispatch/environment_test.go +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -1,10 +1,9 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" + "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -12,21 +11,36 @@ 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()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil + 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") }) - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "fake_alpha", DisableExecutionEnvironment: true})) - if err == nil || called { - t.Fatal("unsupported engine was started", err) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeExecutionPrepare, "none", noEnvironmentPreparation("none", proto.PromptRequestPayload{AgentKind: "fake_alpha"}))) + if err == nil { + t.Fatal("unsupported engine was admitted") } - frames := h.sender.snapshot() - if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal(frames) + 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()) } } @@ -34,14 +48,15 @@ func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { for _, available := range []bool{false, true} { h := newHarness(t) defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true + 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") }) - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "sdk", proto.PromptRequestPayload{AgentKind: "claude_sdk", DisableExecutionEnvironment: true})) - if called != available { - t.Fatalf("factory called=%t, available=%t", called, available) + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "sdk", noEnvironmentPreparation("sdk", 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) } } } @@ -51,26 +66,26 @@ func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { 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", + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(mode != "unsupported")})}, - harnessconfig.Configuration{}, func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - called = true + 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") }) - req := preparationRequest().Configuration - req.AgentKind = "codex" - req.DisableExecutionEnvironment = mode == "none conflict" - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "local", req)) - if called != (mode == "supported") { - t.Fatalf("unexpected factory call for %s", mode) + req := preparationRequest() + req.Configuration.AgentKind = "codex" + req.Configuration.DisableExecutionEnvironment = mode == "none conflict" + 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) } - frames := h.sender.snapshot() - if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal("missing terminal error frames") + 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 b79fa9675..9c18e9ee2 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -38,14 +38,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.WorkspaceAuthoring || 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) @@ -146,7 +145,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) } operation, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} state := "preparing" if reused { state = "ready" @@ -154,7 +153,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), ExecutorID: owner.id, Revision: 1, State: state, Reused: reused, ExpiresAt: p.deadline.UnixMilli()} owner.admission = p r.preparations[p.status.Handle], r.preparationRequests[env.ID] = p, p - p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true) }) if !reused { r.shutdownWG.Add(1) } @@ -278,19 +277,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_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index b99a1ef84..d9cf60fac 100644 --- a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -127,9 +127,7 @@ func TestExecutorCancellationReachesNativeBeforeDurableReceiptJoin(t *testing.T) sender := &receiptCancelSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} owner := &receiptCancelExecutor{turn: make(chan *receiptCancelTurn, 2), cancelFails: mode == "cancel_failure" || mode == "close_failure_retry", closeFailsFirst: mode == "close_failure_retry", closeEntered: make(chan struct{}), closeRelease: make(chan struct{})} reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("legacy factory forbidden") - }) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: time.Hour}) if err != nil { diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index b0fc793fa..0b679aa7f 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -93,10 +93,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } owner := &terminalHandoffExecutor{turns: make(chan *terminalHandoffTurn, 3)} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "handoff", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, - harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("legacy path forbidden") - }) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "handoff", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}) var creates atomic.Int32 registry.RegisterExecutor("handoff", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { creates.Add(1) @@ -150,7 +147,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } } } - request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} + request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: 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 66ec59fba..a2324a78c 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -69,17 +69,25 @@ func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlemen } } +// noEnvironmentPreparation prepares config for session without an execution environment. +func noEnvironmentPreparation(session string, config proto.PromptRequestPayload) proto.ExecutionPreparePayload { + config.AgentStateKey, config.DisableExecutionEnvironment = "agents-api-"+session, true + return proto.ExecutionPreparePayload{SessionID: session, Configuration: config} +} func executorRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "reusable", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} + return noEnvironmentPreparation("session", 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, harnessconfig.Configuration{}) + 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})}, harnessconfig.Configuration{}, 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 }) @@ -220,9 +228,7 @@ func TestExecutorPreInputFailureConfirmsCloseBeforeRetrySignal(t *testing.T) { func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router, *recSender) { t.Helper() registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("unexpected legacy factory") - }) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) registry.RegisterExecutor("reusable", factory) sender := &recSender{} router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, IdleTimeout: time.Minute}) @@ -294,6 +300,25 @@ func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { } } +func TestExecutorRejectsUnsupportedConfigurationBeforeFactory(t *testing.T) { + for name, change := range map[string]func(*proto.PromptRequestPayload){ + "no environment": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = false }, + "unknown agent option": func(r *proto.PromptRequestPayload) { r.AgentOptions = map[string]any{"mode": "plan"} }, + } { + t.Run(name, func(t *testing.T) { + r, s, calls := executorRouter(t, &reusableExecutor{}, time.Minute) + req := executorRequest() + change(&req.Configuration) + 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..6ee90d36e 100644 --- a/apps/daemon/internal/dispatch/export_test.go +++ b/apps/daemon/internal/dispatch/export_test.go @@ -7,32 +7,17 @@ func (r *Router) PreparationOwnershipForTest(handle string) (bool, bool) { return p != nil && p.owns, p != nil && p.busy } -// Test-only re-exports so router_test.go (package dispatch_test) can -// peek into internal state without widening the public surface. - -// AskIndexLenForTest returns the number of asks still indexed at the -// router level. Used to assert cleanup paths. -func (r *Router) AskIndexLenForTest() int { - r.mu.Lock() - defer r.mu.Unlock() - return len(r.askIndex) -} - -// PendingAsksLenForTest reports how many asks the named run still has -// outstanding. Returns -1 if the run is unknown. -func (r *Router) PendingAsksLenForTest(runID string) int { +func (r *Router) SteeringClosedForTest(runID string) bool { r.mu.Lock() defer r.mu.Unlock() - s, ok := r.sessions[runID] - if !ok { - return -1 - } - return len(s.pendingAsks) + state := r.sessions[runID] + return state != nil && state.steeringClosed } -func (r *Router) SteeringClosedForTest(runID string) bool { +// 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.steeringClosed + return state != nil && state.session != nil } diff --git a/apps/daemon/internal/dispatch/functions.go b/apps/daemon/internal/dispatch/functions.go index 89ee63a41..14252711b 100644 --- a/apps/daemon/internal/dispatch/functions.go +++ b/apps/daemon/internal/dispatch/functions.go @@ -5,6 +5,8 @@ import ( "crypto/sha256" "encoding/json" "errors" + "fmt" + "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -29,9 +31,15 @@ func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) e } fingerprint := sha256.Sum256(encoded) // Scope receipt replay to both identities, even when native call IDs repeat across Runs. - kind := proto.TypeFunctionResult + "\x00" + result.CallID - if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, result.DeliveryID, kind, fingerprint); handled { - return err + key := env.ID + "\x00" + result.CallID + r.mu.Lock() + applied, replay := r.applied[key] + r.mu.Unlock() + if replay { + if applied.fingerprint != fingerprint { + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "decision_conflict", "request was already applied with a different decision") + } + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, true, "", "") } r.mu.Lock() state := r.sessions[env.ID] @@ -69,6 +77,43 @@ func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) e } return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, code, "function result was not applied") } - r.rememberAppliedInteractionDecision(env.ID, kind, fingerprint) + r.rememberAppliedFunctionResult(key, fingerprint) return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, true, "", "") } + +func (r *Router) rememberAppliedFunctionResult(key string, fingerprint [32]byte) { + now := time.Now().UTC() + r.mu.Lock() + if len(r.applied) >= 1024 { + cutoff := now.Add(-time.Hour) + for id, entry := range r.applied { + if entry.recordedAt.Before(cutoff) { + delete(r.applied, id) + } + } + } + if len(r.applied) >= 1024 { + for id := range r.applied { + delete(r.applied, id) + break + } + } + r.applied[key] = appliedFunctionResult{fingerprint: fingerprint, recordedAt: now} + r.mu.Unlock() +} + +func (r *Router) sendInteractionDecisionAck(ctx context.Context, requestID, deliveryID string, applied bool, errorCode, message string) error { + env, err := proto.NewEnvelope(proto.TypeInteractionDecisionAck, requestID, proto.InteractionDecisionAckPayload{ + DeliveryID: deliveryID, + Applied: applied, + ErrorCode: errorCode, + Error: message, + }) + if err != nil { + return fmt.Errorf("dispatch: build interaction decision ack: %w", err) + } + if err := r.sender.Send(ctx, env); err != nil { + return fmt.Errorf("dispatch: send interaction decision ack: %w", err) + } + return nil +} diff --git a/apps/daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go index 036ea1c8d..f8fe3879b 100644 --- a/apps/daemon/internal/dispatch/functions_native_test.go +++ b/apps/daemon/internal/dispatch/functions_native_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "encoding/json" @@ -110,13 +108,8 @@ func TestNativeFunctionBridge(t *testing.T) { })) defer model.Close() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, codex.Factory) + 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) - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - if err != nil { - t.Fatal(err) - } - defer router.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) defer cancel() await := func(kind string) proto.Envelope { @@ -135,14 +128,48 @@ func TestNativeFunctionBridge(t *testing.T) { } } } + 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) + } + } + } nativeID := "" - for index := 0; index < 3; index++ { + // 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) - request := proto.PromptRequestPayload{AgentKind: "codex", Input: proto.TextInput("Look up ticket 42."), RunID: run, AgentStateKey: "native-functions", AgentSessionID: nativeID, StrictResume: true, ReleaseOnCompletion: true, DisableExecutionEnvironment: true, ObserveToolObservations: true, + request := noEnvironmentPreparation("native-functions", 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}`)}}, - AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"protocol": "responses", "base_url": model.URL + "/v1", "api_key": "synthetic-local-token"}}} - env, _ := proto.NewEnvelope(proto.TypePromptRequest, run, request) - if err := router.Handle(ctx, env); err != nil { + AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"protocol": "responses", "base_url": model.URL + "/v1", "api_key": "synthetic-local-token"}}}) + prepare, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, run, request) + if err := router.Handle(ctx, prepare); err != nil { + t.Fatal(err) + } + ready := awaitReady(run) + start, _ := proto.NewEnvelope(proto.TypeExecutionStart, run, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: run, Input: proto.TextInput("Look up ticket 42.")}) + if err := router.Handle(ctx, start); err != nil { t.Fatal(err) } call := await(proto.TypeFunctionCall) @@ -169,7 +196,7 @@ func TestNativeFunctionBridge(t *testing.T) { if receipt.Applied || receipt.ErrorCode != "not_pending" { t.Fatal(receipt) } - break + return } result, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: index == 0, Content: functionResultContent("TICKET-RESULT"), DeliveryID: "result"}) if err := router.Handle(ctx, result); err != nil { @@ -190,5 +217,8 @@ func TestNativeFunctionBridge(t *testing.T) { } 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 59be84409..db0bc5d56 100644 --- a/apps/daemon/internal/dispatch/functions_test.go +++ b/apps/daemon/internal/dispatch/functions_test.go @@ -1,16 +1,17 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "bytes" "context" "encoding/base64" "encoding/json" + "errors" + "fmt" "image" "image/color" "image/png" "sync" + "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -38,21 +39,18 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { reg := agent.NewRegistry() sender := &recSender{} sessions := map[string]*functionSession{} - reg.RegisterKind(proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, p proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - s := &functionSession{fakeSession: &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}} - sessions[p.RunID] = s + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, runID string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + s := &functionSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}} + sessions[runID] = s return s, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) if err != nil { t.Fatal(err) } defer router.Shutdown(context.Background()) for _, id := range []string{"one", "two"} { - env, _ := proto.NewEnvelope(proto.TypePromptRequest, id, proto.PromptRequestPayload{AgentKind: "function-test", Input: proto.TextInput("lookup"), FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) - if err := router.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } + startRun(t, router, sender, id, proto.PromptRequestPayload{AgentKind: "function-test", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) } submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { t.Helper() @@ -137,17 +135,20 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { func TestFunctionToolsRequireAdvertisedSupport(t *testing.T) { reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: "unsupported", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil + var called atomic.Bool + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "unsupported", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("unexpected executor preparation") }) sender := &recSender{} router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) defer router.Shutdown(context.Background()) - env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) - if err := router.Handle(t.Context(), env); err == nil || called { - t.Fatal("unsupported engine silently ignored tools", err) + env, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, "run", noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}})) + if err := router.Handle(t.Context(), env); err == nil { + t.Fatal("unsupported engine silently ignored tools") + } + if status := waitPreparationStatus(t, sender, "run", "rejected", ""); status.ErrorCode != "unsupported_configuration" || called.Load() { + t.Fatalf("status=%+v executor called=%t", status, called.Load()) } } @@ -165,17 +166,22 @@ func functionResultContent(text string) []proto.InputContent { func TestDiscoveryCannotReachAnEagerOnlyAdapter(t *testing.T) { reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil + var called atomic.Bool + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("unexpected executor preparation") }) - 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()) for _, search := range []bool{false, true} { - env, _ := proto.NewEnvelope(proto.TypePromptRequest, "discovery", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}}) - if err := router.Handle(t.Context(), env); err == nil || called { - t.Fatal("deferred definitions reached an eager-only adapter", err) + id := fmt.Sprint("discovery-", search) + env, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, id, noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}})) + if err := router.Handle(t.Context(), env); err == nil { + t.Fatal("deferred definitions reached an eager-only adapter") + } + if status := waitPreparationStatus(t, sender, id, "rejected", ""); status.ErrorCode != "unsupported_configuration" || called.Load() { + t.Fatalf("status=%+v executor called=%t", status, called.Load()) } } } diff --git a/apps/daemon/internal/dispatch/interaction_decisions.go b/apps/daemon/internal/dispatch/interaction_decisions.go deleted file mode 100644 index 77ed0fd8c..000000000 --- a/apps/daemon/internal/dispatch/interaction_decisions.go +++ /dev/null @@ -1,328 +0,0 @@ -package dispatch - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func (r *Router) handlePermissionDecision(ctx context.Context, env proto.Envelope) error { - if env.ID == "" { - return errors.New("dispatch: permission_decision missing perm id (Envelope.ID empty)") - } - var payload proto.PermissionDecisionPayload - if err := env.DecodePayload(&payload); err != nil { - return fmt.Errorf("dispatch: decode permission_decision: %w", err) - } - if payload.DeliveryID == "" { - return errors.New("dispatch: permission_decision missing delivery_id") - } - fingerprint, err := interactionDecisionFingerprint(payload) - if err != nil { - return fmt.Errorf("dispatch: fingerprint permission_decision: %w", err) - } - if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, payload.DeliveryID, proto.TypePermissionDecision, fingerprint); handled { - return err - } - - r.mu.Lock() - runID, known := r.permIndex[env.ID] - var state *sessionState - var session agent.Session - var finishOperation func() - if known { - state = r.sessions[runID] - if state != nil { - session, finishOperation, _ = r.preparedOperationLocked(state) - } - } - r.mu.Unlock() - - if !known || state == nil { - // Server's perm timeout / cancel race; common enough that info - // is right. - r.log.InfoContext(ctx, "permission_decision for unknown perm (run gone)", "perm_id", env.ID) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", "permission request is no longer pending") - } - if session == nil { - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_ready", "permission request is waiting for the native session") - } - defer finishOperation() - ctx, stop := r.shutdownContext(ctx) - defer stop() - - if !state.capabilities.Permissions.IsSupported() { - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "unsupported", "The runtime declaration does not support interaction decisions.") - } - responder, supported := session.(agent.PermissionResponder) - if !supported { - r.dropPermission(state, env.ID) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "contract_violation", "Declared runtime capability does not implement permission responses") - } - if err := responder.SubmitPermission(ctx, env.ID, payload); err != nil { - if errors.Is(err, agent.ErrUnsupportedOperation) { - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "contract_violation", "Declared permission capability has no implementation.") - } - if errors.Is(err, agent.ErrUnknownPermission) { - r.log.InfoContext(ctx, "agent reports unknown perm (race with cancel)", "perm_id", env.ID, "run_id", runID) - r.dropPermission(state, env.ID) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", err.Error()) - } - r.log.WarnContext(ctx, "agent rejected permission decision", "perm_id", env.ID, "run_id", runID, "err", err) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "runtime_error", err.Error()) - } - r.dropPermission(state, env.ID) - r.rememberAppliedInteractionDecision(env.ID, proto.TypePermissionDecision, fingerprint) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, true, "", "") -} - -func (r *Router) dropPermission(s *sessionState, permissionID string) { - r.mu.Lock() - delete(r.permIndex, permissionID) - delete(s.pendingIDs, permissionID) - r.mu.Unlock() -} - -// handlePromptForUserChoiceDecision is the ask-side twin of -// handlePermissionDecision. The server forwards the human's answer -// here; we look up the owning session via askIndex and ask the agent -// to write a matching tool_result back into its CLI. -// -// On both success and ErrUnknownAsk we drop the ask from askIndex / -// pendingAsks so a stale retry can't waste cycles. Timer-fired cancels -// inside the session don't currently call back into the router, so -// 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. -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)") - } - var payload proto.PromptForUserChoiceDecisionPayload - if err := env.DecodePayload(&payload); err != nil { - return fmt.Errorf("dispatch: decode prompt_for_user_choice_decision: %w", err) - } - if payload.DeliveryID == "" { - return errors.New("dispatch: prompt_for_user_choice_decision missing delivery_id") - } - fingerprint, err := interactionDecisionFingerprint(payload) - if err != nil { - return fmt.Errorf("dispatch: fingerprint prompt_for_user_choice_decision: %w", err) - } - if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, payload.DeliveryID, proto.TypePromptForUserChoiceDecision, fingerprint); handled { - return err - } - - r.mu.Lock() - runID, known := r.askIndex[env.ID] - var state *sessionState - var session agent.Session - var finishOperation func() - if known { - state = r.sessions[runID] - if state != nil { - session, finishOperation, _ = r.preparedOperationLocked(state) - } - } - r.mu.Unlock() - - if !known || state == nil { - r.log.InfoContext(ctx, "prompt_for_user_choice_decision for unknown ask (run gone)", "ask_id", env.ID) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", "user-input request is no longer pending") - } - if session == nil { - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_ready", "user-input request is waiting for the native session") - } - defer finishOperation() - ctx, stop := r.shutdownContext(ctx) - defer stop() - - if !state.capabilities.Permissions.IsSupported() { - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "unsupported", "The runtime declaration does not support interaction decisions.") - } - responder, supported := session.(agent.UserChoiceResponder) - if !supported { - r.dropAsk(state, env.ID) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "contract_violation", "Declared runtime capability does not implement user-choice responses") - } - err = responder.SubmitPromptForUserChoice(ctx, env.ID, payload) - if err != nil { - if errors.Is(err, agent.ErrUnsupportedOperation) { - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "contract_violation", "Declared user-choice capability has no implementation.") - } - if errors.Is(err, agent.ErrUnknownAsk) { - r.log.InfoContext(ctx, "agent reports unknown ask (race with cancel)", "ask_id", env.ID, "run_id", runID) - r.dropAsk(state, env.ID) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "not_pending", err.Error()) - } - // Keep the routing entry for transient runtime failures. Codex, for - // example, restores its pending request when a JSON-RPC reply write - // fails, so dropping the ask here would turn a retryable error into a - // permanent not_pending response on the next attempt. - r.log.WarnContext(ctx, "agent rejected user-input decision", "ask_id", env.ID, "run_id", runID, "err", err) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, false, "runtime_error", err.Error()) - } - r.dropAsk(state, env.ID) - r.rememberAppliedInteractionDecision(env.ID, proto.TypePromptForUserChoiceDecision, fingerprint) - return r.sendInteractionDecisionAck(ctx, env.ID, payload.DeliveryID, true, "", "") -} - -func (r *Router) replayAppliedInteractionDecision(ctx context.Context, requestID, deliveryID, kind string, fingerprint [32]byte) (bool, error) { - key := appliedInteractionDecisionKey(requestID, kind) - r.mu.Lock() - applied, ok := r.applied[key] - r.mu.Unlock() - if !ok { - return false, nil - } - if applied.requestID != requestID || applied.kind != kind || applied.fingerprint != fingerprint { - return true, r.sendInteractionDecisionAck(ctx, requestID, deliveryID, false, "decision_conflict", "request was already applied with a different decision") - } - return true, r.sendInteractionDecisionAck(ctx, requestID, deliveryID, true, "", "") -} - -func (r *Router) rememberAppliedInteractionDecision(requestID, kind string, fingerprint [32]byte) { - now := time.Now().UTC() - key := appliedInteractionDecisionKey(requestID, kind) - r.mu.Lock() - if len(r.applied) >= 1024 { - cutoff := now.Add(-time.Hour) - for id, entry := range r.applied { - if entry.recordedAt.Before(cutoff) { - delete(r.applied, id) - } - } - } - if len(r.applied) >= 1024 { - for id := range r.applied { - delete(r.applied, id) - break - } - } - r.applied[key] = appliedInteractionDecision{ - requestID: requestID, kind: kind, fingerprint: fingerprint, recordedAt: now, - } - r.mu.Unlock() -} - -func appliedInteractionDecisionKey(requestID, kind string) string { - return kind + "\x00" + requestID -} - -// interactionDecisionFingerprint excludes the transport delivery id. Each -// retry gets a fresh delivery id so a late ack cannot satisfy a newer waiter, -// while the request plus decision content remains stable for daemon replay. -func interactionDecisionFingerprint(payload any) ([32]byte, error) { - switch decision := payload.(type) { - case proto.PermissionDecisionPayload: - decision.DeliveryID = "" - encoded, err := json.Marshal(decision) - if err != nil { - return [32]byte{}, err - } - return sha256.Sum256(encoded), nil - case proto.PromptForUserChoiceDecisionPayload: - decision.DeliveryID = "" - encoded, err := json.Marshal(decision) - if err != nil { - return [32]byte{}, err - } - return sha256.Sum256(encoded), nil - default: - return [32]byte{}, fmt.Errorf("unsupported interaction decision %T", payload) - } -} - -func (r *Router) sendInteractionDecisionAck(ctx context.Context, requestID, deliveryID string, applied bool, errorCode, message string) error { - env, err := proto.NewEnvelope(proto.TypeInteractionDecisionAck, requestID, proto.InteractionDecisionAckPayload{ - DeliveryID: deliveryID, - Applied: applied, - ErrorCode: errorCode, - Error: message, - }) - if err != nil { - return fmt.Errorf("dispatch: build interaction decision ack: %w", err) - } - if err := r.sender.Send(ctx, env); err != nil { - return fmt.Errorf("dispatch: send interaction decision ack: %w", err) - } - return nil -} - -// dropAsk clears askID from both the router-level askIndex and the -// session's pendingAsks set. Safe to call with an askID that's already -// gone — both deletes are no-ops then. -func (r *Router) dropAsk(s *sessionState, askID string) { - r.mu.Lock() - delete(r.askIndex, askID) - delete(s.pendingAsks, askID) - r.mu.Unlock() -} - -// indexPermissionFrame records interaction identities before forwarding them. -// Prepared output may arrive before successful Session publication; decisions -// remain retryable until state.session becomes available. -func (r *Router) indexPermissionFrame(s *sessionState, env proto.Envelope) { - switch env.Type { - case proto.TypePermissionRequest: - var p proto.PermissionRequestPayload - if err := env.DecodePayload(&p); err != nil { - return - } - requestID := strings.TrimSpace(p.RequestID) - if requestID == "" { - return - } - r.mu.Lock() - if r.interactionRouteOpenLocked(s) { - r.permIndex[requestID] = s.runID - s.pendingIDs[requestID] = struct{}{} - } - r.mu.Unlock() - case proto.TypePermissionCancel: - if env.ID == "" { - return - } - r.mu.Lock() - delete(r.permIndex, env.ID) - delete(s.pendingIDs, env.ID) - r.mu.Unlock() - case proto.TypePromptForUserChoice: - // env.ID is the run id (so the server-side dispatch can fan - // this frame to the run's subscriber); the ask id rides on - // the payload. Decode just enough to seed the index. - var p proto.PromptForUserChoicePayload - if err := env.DecodePayload(&p); err != nil || p.AskID == "" { - return - } - r.mu.Lock() - if r.interactionRouteOpenLocked(s) { - r.askIndex[p.AskID] = s.runID - s.pendingAsks[p.AskID] = struct{}{} - } - r.mu.Unlock() - } -} - -func (r *Router) clearInteractionRoutesLocked(s *sessionState) { - for permissionID := range s.pendingIDs { - delete(r.permIndex, permissionID) - delete(s.pendingIDs, permissionID) - } - for askID := range s.pendingAsks { - delete(r.askIndex, askID) - delete(s.pendingAsks, askID) - } -} - -// drain consumes everything left on ch until the agent closes it. -// Events are dropped — by the time we're draining, either transport -// is dead or the router is shutting down. diff --git a/apps/daemon/internal/dispatch/local_directory.go b/apps/daemon/internal/dispatch/local_directory.go deleted file mode 100644 index 02c1a4fa4..000000000 --- a/apps/daemon/internal/dispatch/local_directory.go +++ /dev/null @@ -1,20 +0,0 @@ -package dispatch - -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type localDirectoryPreparation struct{} - -func prepareLocalDirectory(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return localDirectoryPreparation{}, nil -} - -func (localDirectoryPreparation) Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) { - return nil, agent.ErrWorkspaceReadUnsupported -} - -func (localDirectoryPreparation) Close() error { return nil } diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index b0363bf24..8319fbecc 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -23,17 +23,14 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) if err != nil { t.Fatal(err) } var harnessCalls atomic.Int32 reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - harnessCalls.Add(1) - return nil, errors.New("must not start a model") - }) - reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { harnessCalls.Add(1) return nil, errors.New("must not prepare a harness") }) @@ -43,7 +40,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing t.Fatal(err) } t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) - 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{Configuration: request})); err != nil { t.Fatal(err) } @@ -106,15 +103,13 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) if err != nil { t.Fatal(err) } reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("must not start a model") - }) - reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, errors.New("must not prepare a harness") }) sender := &recSender{} @@ -123,7 +118,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) - 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{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 45ae2fe80..3ba64f237 100644 --- a/apps/daemon/internal/dispatch/mcp_http_test.go +++ b/apps/daemon/internal/dispatch/mcp_http_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "strings" + "sync/atomic" "testing" "time" @@ -16,33 +17,38 @@ 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("mcp-bearer", proto.PromptRequestPayload{AgentKind: "codex"}) + if strings.HasSuffix(mode, "local") { + h = localPreparationHarness(t) + prepare = preparationRequest() + prepare.Configuration.AgentKind = "codex" + } else { + h = newHarness(t) + } 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 := proto.PromptRequestPayload{AgentKind: "codex", 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": @@ -51,13 +57,13 @@ 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}, - harnessconfig.Configuration{}, func(_ context.Context, got proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - called = true + 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") } @@ -66,14 +72,15 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { } return nil, errors.New("controlled factory stop") }) - err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) - if called != (mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { - t.Fatal("wrong factory admission") + 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) } - frames := h.sender.snapshot() - raw, _ := json.Marshal(frames) - if err == nil || strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) || len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal("terminal rejection missing or exposed credential") + raw, _ := json.Marshal(h.sender.snapshot()) + if err != nil && strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) { + t.Fatal("terminal status exposed credential") } }) } @@ -105,10 +112,7 @@ func TestLocalMCPOriginAndCapabilityAdmission(t *testing.T) { req.Configuration.MCPHTTPServers = nil } entered := make(chan struct{}, 1) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(mode != "environment missing capability"), MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - t.Error("ordinary factory called") - return nil, errors.New("unexpected") - }) + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(mode != "environment missing capability"), MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}, harnessconfig.Configuration{}) h.reg.RegisterExecutor("prepared", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { entered <- struct{}{} return nil, errors.New("controlled stop") diff --git a/apps/daemon/internal/dispatch/optional_interactions_test.go b/apps/daemon/internal/dispatch/optional_interactions_test.go deleted file mode 100644 index dc63be1eb..000000000 --- a/apps/daemon/internal/dispatch/optional_interactions_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - -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 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "minimal", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, 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 - }) - if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "minimal"})); err != nil { - t.Fatal(err) - } - event := mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "interaction", Tool: "fixture"}) - decision := mustEnv(t, proto.TypePermissionDecision, "interaction", proto.PermissionDecisionPayload{DeliveryID: "decision", Approved: true}) - if ask { - event = mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "interaction"}) - decision = mustEnv(t, 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 len(h.sender.snapshot()) > 0 }, "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/output.go b/apps/daemon/internal/dispatch/output.go deleted file mode 100644 index e0e96dca9..000000000 --- a/apps/daemon/internal/dispatch/output.go +++ /dev/null @@ -1,103 +0,0 @@ -package dispatch - -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -// pump forwards every Envelope the session writes onto out to the -// upstream sender, then cleans up when the session closes out. -func (r *Router) pump(s *sessionState) { - defer r.shutdownWG.Done() - defer r.cleanupSession(s) - _ = r.forwardSessionOutput(s, true) -} - -// wait is false only after a failed Start and its cancellation have returned: -// no Session owns the output sink, so only already queued frames can be read. -func (r *Router) forwardSessionOutput(s *sessionState, wait bool) error { - // Logging-only ctx carrying the run's trace; sends use their own - // ctx tied to shutdownCh. - pumpCtx := context.Background() - if s.traceparent != "" { - if carrier, err := obslog.ParseTraceparent(s.traceparent); err == nil { - pumpCtx = obslog.WithTrace(pumpCtx, carrier) - } - } - r.log.InfoContext(pumpCtx, "pump: started", "run_id", s.runID) - - // Long-lived send ctx — must keep forwarding even after the - // session's ctx is cancelled (session might emit a final "done" - // in response to cancel). Stops on out close or router shutdown. - for { - if !wait && len(s.out) == 0 { - return nil - } - select { - case env, ok := <-s.out: - if !ok { - r.log.InfoContext(pumpCtx, "pump: out channel closed", "run_id", s.runID) - return nil - } - if s.session != nil { - r.indexPermissionFrame(s, env) - } - if env.Type == proto.TypeDone && s.releaseOnCompletion { - if err := r.releaseCompletedSession(s); err != nil { - sendCtx, stop := r.shutdownContext(pumpCtx) - r.emitTerminalError(sendCtx, s.runID, "failed to release completed executor") - stop() - continue - } - } - r.log.InfoContext(pumpCtx, "pump: forwarding envelope", "run_id", s.runID, "type", env.Type, "env_id", env.ID) - // Stamp the run's trace onto outbound frames so the - // gateway attributes daemon-emitted lines to the same - // trace_id as the original prompt_request. - if env.Trace == "" && s.traceparent != "" { - env.Trace = s.traceparent - } - // Short-ish send ctx that respects router shutdown — if - // the transport is wedged we don't want to block forever. - sendCtx, cancel := context.WithCancel(context.Background()) - stopOnShutdown := make(chan struct{}) - go func() { - select { - case <-r.shutdownCh: - cancel() - case <-stopOnShutdown: - } - }() - err := r.sender.Send(sendCtx, env) - close(stopOnShutdown) - cancel() - if err != nil { - // Sender failed — log, ask the session to wind down, - // but KEEP draining out so the agent's goroutines - // don't block on a full channel. - r.log.ErrorContext(pumpCtx, "send envelope failed", "type", env.Type, "run_id", env.ID, "err", err) - r.mu.Lock() - s.retain = false - r.mu.Unlock() - s.ctxCancel() - if wait { - r.drain(s.out) - } - return err - } - case <-r.shutdownCh: - // Router shutdown — cancel + drain so the session's - // goroutines unblock and close out cleanly. - r.mu.Lock() - s.retain = false - r.mu.Unlock() - s.ctxCancel() - if wait { - r.drain(s.out) - } - return ErrRouterClosed - } - } -} diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index e77761f09..fdfe21631 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -19,25 +18,22 @@ const preparationRecords = 64 // All mutable fields are protected by Router.mu. owns includes resources whose // cancellation is underway; a slow close cannot bypass the capacity bound. type preparationState struct { - capabilities proto.AgentKindCapabilities - executor *executorState - requestID string - trace string - fingerprint [32]byte - startFingerprint [32]byte - status proto.PreparationStatusPayload - deadline time.Time - timer *time.Timer - ctx context.Context - cancel context.CancelFunc - prepared agent.Prepared - stateKey string - environmentID string - busy bool - owns bool - closeErr error - workspaceReadOnly bool - handoff *preparedHandoff + capabilities proto.AgentKindCapabilities + executor *executorState + requestID string + trace string + fingerprint [32]byte + startFingerprint [32]byte + status proto.PreparationStatusPayload + deadline time.Time + timer *time.Timer + ctx context.Context + cancel context.CancelFunc + environmentID string + busy bool + owns bool + closeErr error + handoff *preparedHandoff } func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) error { @@ -53,25 +49,22 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if !available { return r.rejectPreparation(env, "resource_unavailable") } - prepare, err := r.registry.ResolvePreparation(req.AgentKind) - if err != nil || !caps.Preparation.IsSupported() { + if !caps.Preparation.IsSupported() { return r.rejectPreparation(env, "unsupported_preparation") } - if req.WorkspaceReadOnly && (!caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req)) { + if !caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req) { return r.rejectPreparation(env, "unsupported_read_preparation") } - if req, err = r.localWorkspace.Configure(req); err != nil { + req, err := r.localWorkspace.Configure(req) + if err != nil { return r.rejectPreparation(env, "invalid_configuration") } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.WorkspaceAuthoring || 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()) { return r.rejectPreparation(env, "unsupported_configuration") } - if req.LocalEnvironment != nil && req.WorkspaceReadOnly { - prepare = prepareLocalDirectory - } encoded, err := json.Marshal(req) if err != nil { return r.rejectPreparation(env, "invalid_configuration") @@ -82,7 +75,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) r.mu.Unlock() return ErrRouterClosed } - if r.runtimePreparation != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { + if r.runtimePreparation != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } @@ -108,60 +101,44 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "preparation_capacity") } owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p - p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true) }) r.shutdownWG.Add(1) r.mu.Unlock() - go r.prepareExecution(p, req, prepare) + go r.prepareExecution(p) return nil } -func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPayload, prepare agent.PreparationFactory) { +// prepareExecution readies a read-only preparation without starting a Harness. +func (r *Router) prepareExecution(p *preparationState) { defer r.shutdownWG.Done() if !r.sendPreparation(p.requestID, p.trace, proto.PreparationStatusPayload{Handle: p.status.Handle, Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()}) { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) - } - var prepared agent.Prepared - var err error - if p.ctx.Err() == nil { - prepared, err = prepare(p.ctx, req) - } else { - err = p.ctx.Err() - } - if err == nil && prepared != nil && !p.workspaceReadOnly { - if _, ok := prepared.(agent.PreparedCancellation); !ok { - err = errors.New("executable preparation requires cross-transfer cancellation") - } + r.releasePreparation(p, "failed", "status_delivery_failed", false) } r.mu.Lock() p.busy = false - p.prepared = prepared - ready := err == nil && prepared != nil && p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed + ready := p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed if ready { p.status.State, p.status.Revision = "ready", p.status.Revision+1 - } else if p.status.State == "preparing" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 - } - status := p.status - if !ready { - p.busy = true + } else { + if p.status.State == "preparing" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 + } + // A release or shutdown during readiness left ownership to this return. + p.owns = false p.cancel() p.timer.Stop() } + status := p.status r.mu.Unlock() if !ready { - r.closePreparationResource(p) - if p.workspaceReadOnly { - return - } - r.mu.Lock() - status = p.status - r.mu.Unlock() + r.publishPreparation(p, status) + return } - if !r.sendPreparation(p.requestID, p.trace, status) && ready { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + if !r.sendPreparation(p.requestID, p.trace, status) { + r.releasePreparation(p, "failed", "status_delivery_failed", false) } } @@ -177,11 +154,11 @@ func (r *Router) handleExecutionRelease(_ context.Context, env proto.Envelope) e if !valid { return r.rejectPreparation(env, "unknown_preparation") } - r.releasePreparation(p, "released", "", true, true) + r.releasePreparation(p, "released", "", true) return nil } -func (r *Router) releasePreparation(p *preparationState, state, code string, publish, retryHandoff bool) { +func (r *Router) releasePreparation(p *preparationState, state, code string, publish bool) { r.mu.Lock() if r.closed || r.suspension != nil { r.mu.Unlock() @@ -192,48 +169,21 @@ func (r *Router) releasePreparation(p *preparationState, state, code string, pub r.abandonExecutorAdmission(p, state, code, publish) return } - if p.handoff != nil { - if active := r.sessions[p.status.RunID]; active != nil && active.preparedHandoff == p.handoff { - if state == "expired" && p.handoff.published { - r.mu.Unlock() - return - } - switch p.status.State { - case "preparing", "ready", "starting", "started": - p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 - p.timer.Stop() - } - r.claimPreparedReleaseLocked(active, true, "", retryHandoff) - status := p.status - r.mu.Unlock() - if publish { - r.publishPreparation(p, status) - } - return - } - } - closeResource := false switch p.status.State { - case "preparing", "ready", "starting": + case "preparing", "ready": p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 p.cancel() p.timer.Stop() } - if p.owns && !p.busy { - if p.workspaceReadOnly && state == "released" && p.status.ErrorCode == "cleanup_unconfirmed" { - p.status.State, p.status.ErrorCode, p.status.Revision = state, "", p.status.Revision+1 - } - p.busy = true - closeResource = true - r.shutdownWG.Add(1) + // A busy preparation drops ownership and reports when readiness returns. + // Dropping ownership here always reports it. + report := !p.busy && (publish || p.owns) + if !p.busy { + p.owns = false } status := p.status - settled := !p.owns && !p.busy r.mu.Unlock() - if closeResource { - go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() - } - if publish && (!p.workspaceReadOnly || settled) { + if report { r.publishPreparation(p, status) } } @@ -259,11 +209,9 @@ func (r *Router) prunePreparationsLocked() { func (r *Router) publishPreparation(p *preparationState, status proto.PreparationStatusPayload) { r.mu.Lock() - if p.workspaceReadOnly { - if status.Revision != p.status.Revision || (p.owns && (p.busy || status.State == "released" || status.State == "expired") && status.State != "preparing" && status.State != "ready") { - r.mu.Unlock() - return - } + if p.executor == nil && status.Revision != p.status.Revision { + r.mu.Unlock() + return } if r.closed || r.suspension != nil { r.mu.Unlock() @@ -274,8 +222,8 @@ func (r *Router) publishPreparation(p *preparationState, status proto.Preparatio go func() { defer r.shutdownWG.Done() // A failed terminal notification must not restart incomplete cleanup. - if !r.sendPreparation(p.requestID, p.trace, status) && (!p.workspaceReadOnly || status.State == "preparing" || status.State == "ready") { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + if !r.sendPreparation(p.requestID, p.trace, status) && (p.executor != nil || status.State == "preparing" || status.State == "ready") { + r.releasePreparation(p, "failed", "status_delivery_failed", false) } }() } diff --git a/apps/daemon/internal/dispatch/preparation_cancel_test.go b/apps/daemon/internal/dispatch/preparation_cancel_test.go index b21a5c21c..5804650cb 100644 --- a/apps/daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/daemon/internal/dispatch/preparation_cancel_test.go @@ -28,20 +28,6 @@ func (p *cancellationPreparation) Cancel(ctx context.Context) error { func (p *cancellationPreparation) CancellationOutcome() proto.DonePayload { return p.outcome } -type nonCancellablePreparation struct { - closed chan struct{} - once sync.Once -} - -func (*nonCancellablePreparation) Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("must not start") -} - -func (p *nonCancellablePreparation) Close() error { - p.once.Do(func() { close(p.closed) }) - return nil -} - func startCancellationPreparation(t *testing.T, r *dispatch.Router, sender *recSender) proto.PreparationStatusPayload { t.Helper() if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { @@ -100,8 +86,6 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { session = &fakeSession{out: out, closeOutOnCancel: true, postCancelEnvelopes: []proto.Envelope{mustEnv(t, proto.TypeDone, id, p.outcome)}} out <- mustEnv(t, proto.TypeDelta, id, proto.DeltaPayload{Delta: "observed"}) - out <- mustEnv(t, proto.TypePermissionRequest, id, proto.PermissionRequestPayload{RequestID: "permission"}) - out <- mustEnv(t, proto.TypePromptForUserChoice, id, proto.PromptForUserChoicePayload{AskID: "ask"}) out <- mustEnv(t, proto.TypeUsage, id, proto.UsagePayload{Usage: p.outcome.Usage}) close(startEntered) <-startReturn @@ -116,7 +100,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { <-cleanupReturn return nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-startEntered if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { @@ -138,16 +122,6 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { if len(cancellationAcks(sender.recSender)) != 0 || r.ActiveRuns() != 1 { t.Fatal("cleanup released ownership early") } - for _, decision := range []proto.Envelope{ - mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: "permission-reply", Approved: true}), - mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "ask-reply", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}), - } { - if err := r.Handle(t.Context(), decision); err != nil { - t.Fatal(err) - } - } - assertDecisionAck(t, sender.recSender, "permission-reply", false, "not_pending") - assertDecisionAck(t, sender.recSender, "ask-reply", false, "not_pending") close(cleanupReturn) waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 && r.ActiveRuns() == 0 }, "prepared cancellation receipts") if p.calls.Load() != 1 || session.cancels() != 1 { @@ -159,7 +133,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { } } got := sender.typesFor("run") - want := []string{proto.TypeDelta, proto.TypePermissionRequest, proto.TypePromptForUserChoice, proto.TypeUsage, proto.TypeDone, proto.TypeInteractionDecisionAck, proto.TypeInteractionDecisionAck} + want := []string{proto.TypeDelta, proto.TypeUsage, proto.TypeDone, proto.TypeInteractionDecisionAck, proto.TypeInteractionDecisionAck} if !reflect.DeepEqual(got, want) { t.Fatalf("output/receipt order = %v", got) } @@ -186,7 +160,7 @@ func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) if boundary == "expiry" { timeout = 100 * time.Millisecond } - r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -243,7 +217,7 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -276,27 +250,6 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { } } -func TestExecutablePreparationRequiresCrossTransferCancellation(t *testing.T) { - sender := &recSender{} - p := &nonCancellablePreparation{closed: make(chan struct{})} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { - t.Fatal(err) - } - waitPreparationStatus(t, sender, "request", "failed", "") - select { - case <-p.closed: - case <-time.After(time.Second): - t.Fatal("unsupported executable preparation was not closed") - } - for _, frame := range sender.snapshot() { - var status proto.PreparationStatusPayload - if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "ready" { - t.Fatal("unsupported executable preparation became ready") - } - } -} - func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) { sender := &recSender{} entered, allowReturn := make(chan struct{}), make(chan struct{}) @@ -308,7 +261,7 @@ func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) return nil, ctx.Err() } var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { if count.Add(1) == 1 { return p, nil } diff --git a/apps/daemon/internal/dispatch/preparation_cleanup.go b/apps/daemon/internal/dispatch/preparation_cleanup.go index 2a51c0e8c..97b77dbc6 100644 --- a/apps/daemon/internal/dispatch/preparation_cleanup.go +++ b/apps/daemon/internal/dispatch/preparation_cleanup.go @@ -1,35 +1,6 @@ package dispatch -func (r *Router) closePreparationResource(p *preparationState) error { - // Only the operation that owns busy calls this; other paths cancel its owner. - var err error - if p.prepared != nil { - err = p.prepared.Close() - } - r.mu.Lock() - p.busy, p.closeErr = false, err - if err == nil { - p.prepared, p.owns = nil, false - } - if p.workspaceReadOnly { - p.status.Revision++ - if err != nil { - p.status.State, p.status.ErrorCode = "failed", "cleanup_unconfirmed" - } - } - status := p.status - r.mu.Unlock() - if p.workspaceReadOnly { - r.publishPreparation(p, status) - } - if err != nil { - r.log.Warn("preparation cleanup incomplete", "handle", p.status.Handle) - } - return err -} - -func (r *Router) closePendingPreparationsLocked() []*preparationState { - var closeNow []*preparationState +func (r *Router) closePendingPreparationsLocked() { for _, p := range r.preparations { p.timer.Stop() if p.executor != nil { @@ -38,19 +9,14 @@ func (r *Router) closePendingPreparationsLocked() []*preparationState { if !p.owns { continue } - if p.handoff != nil { - continue - } p.cancel() switch p.status.State { - case "preparing", "ready", "starting": + case "preparing", "ready": p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "connection_closed", p.status.Revision+1 } + // A busy preparation drops ownership when readiness returns. if !p.busy { - p.busy = true - r.shutdownWG.Add(1) - closeNow = append(closeNow, p) + p.owns = false } } - return closeNow } diff --git a/apps/daemon/internal/dispatch/preparation_cleanup_test.go b/apps/daemon/internal/dispatch/preparation_cleanup_test.go index 20a45212b..b88d21b73 100644 --- a/apps/daemon/internal/dispatch/preparation_cleanup_test.go +++ b/apps/daemon/internal/dispatch/preparation_cleanup_test.go @@ -43,7 +43,7 @@ func TestPreparedCancellationDoesNotAcknowledgeFailedCleanup(t *testing.T) { return nil, context.Canceled } sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -74,7 +74,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) waitPreparationStatus(t, sender, "request", "started", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { @@ -105,7 +105,7 @@ func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { return nil }} sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) waitPreparationStatus(t, sender, "request", "ready", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { @@ -126,7 +126,7 @@ func TestShutdownTimeoutAndConcurrentRetryWaitForCleanup(t *testing.T) { unblock := func() { once.Do(func() { close(release) }) } p := &retryablePreparation{close: func(int32) error { close(entered); <-release; return nil }} sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) t.Cleanup(unblock) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) waitPreparationStatus(t, sender, "request", "ready", "") @@ -176,7 +176,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { return session.Cancel(ctx) } var factories atomic.Int32 - r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { if factories.Add(1) == 1 { return p, nil } diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index a21823c6a..0ab84ce26 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -2,30 +2,39 @@ package dispatch_test import ( "context" - "errors" "sync" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +// preparedFixture is a disposable fault-injection resource; Start transfers it +// to one Session. A cancellablePreparation follows that Session across Start. +type preparedFixture interface { + Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) + Close() error +} + +type cancellablePreparation interface { + preparedFixture + agent.Session +} + +type preparationFactory func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) + // Old fault-injection fixtures model disposable executors, not reusable native implementations. -func preparationExecutorFixture(factory agent.PreparationFactory) agent.ExecutorFactory { +func preparationExecutorFixture(factory preparationFactory) agent.ExecutorFactory { return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { prepared, err := factory(ctx, req) if prepared == nil { return nil, err } - owner := &preparationExecutor{prepared: prepared} - if _, ok := prepared.(agent.PreparedCancellation); !ok { - return owner, errors.New("fixture has no cancellation target") - } - return owner, err + return &preparationExecutor{prepared: prepared}, err } } type preparationExecutor struct { - prepared agent.Prepared + prepared preparedFixture mu sync.Mutex cancelled bool } @@ -39,7 +48,7 @@ func (e *preparationExecutor) Close(ctx context.Context) error { var err error if resource, ok := e.prepared.(*retryablePreparation); ok { err = resource.Close() - } else if cancel, ok := e.prepared.(agent.PreparedCancellation); ok { + } else if cancel, ok := e.prepared.(cancellablePreparation); ok { err = cancel.Cancel(ctx) } else { err = e.prepared.Close() @@ -79,7 +88,7 @@ type preparationTurn struct { func (t *preparationTurn) Cancel(ctx context.Context) error { return t.owner.Close(ctx) } func (t *preparationTurn) CancellationOutcome() proto.DonePayload { - if target, ok := t.owner.prepared.(agent.PreparedCancellation); ok { + if target, ok := t.owner.prepared.(cancellablePreparation); ok { return target.CancellationOutcome() } return proto.DonePayload{} @@ -113,18 +122,6 @@ func (t *preparationTurn) SteerWithReceipt(ctx context.Context, p proto.PromptSt } return agent.ErrSteeringInactive } -func (t *preparationTurn) SubmitPermission(ctx context.Context, id string, p proto.PermissionDecisionPayload) error { - if target, ok := t.Session.(agent.PermissionResponder); ok { - return target.SubmitPermission(ctx, id, p) - } - return agent.ErrUnknownPermission -} -func (t *preparationTurn) SubmitPromptForUserChoice(ctx context.Context, id string, p proto.PromptForUserChoiceDecisionPayload) error { - if target, ok := t.Session.(agent.UserChoiceResponder); ok { - return target.SubmitPromptForUserChoice(ctx, id, p) - } - return agent.ErrUnknownAsk -} func (t *preparationTurn) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { if target, ok := t.Session.(agent.WorkspaceReader); ok { return target.ReadWorkspaceFile(ctx, path, limit) diff --git a/apps/daemon/internal/dispatch/preparation_start.go b/apps/daemon/internal/dispatch/preparation_start.go index fac37084c..887f42f11 100644 --- a/apps/daemon/internal/dispatch/preparation_start.go +++ b/apps/daemon/internal/dispatch/preparation_start.go @@ -33,12 +33,12 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return r.rejectPreparation(env, "unknown_preparation") } - if p.workspaceReadOnly { + if p.executor == nil { r.mu.Unlock() return r.rejectPreparation(env, "read_only_preparation") } owner := p.executor - if owner == nil || owner.id != input.ExecutorID { + if owner.id != input.ExecutorID { r.mu.Unlock() return r.rejectPreparation(env, "unknown_executor") } @@ -61,7 +61,7 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err } if !time.Now().Before(p.deadline) { r.mu.Unlock() - r.releasePreparation(p, "expired", "", true, true) + r.releasePreparation(p, "expired", "", true) return nil } if r.sessions[input.RunID] != nil { @@ -70,7 +70,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{capabilities: p.capabilities, runID: input.RunID, stateKey: p.stateKey, 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{capabilities: p.capabilities, runID: input.RunID, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, 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 7d4a9de4e..eca2ea195 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -113,16 +113,13 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + 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 agent.PreparationFactory) *dispatch.Router { +func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { t.Helper() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, Permissions: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary Factory must not be used for preparation") - }) - reg.RegisterPreparation("prepared", true, factory) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) if err != nil { @@ -167,7 +164,7 @@ func TestPreparationReleaseDuringBlockedFactory(t *testing.T) { sender := &recSender{} p := &controlledPreparation{closed: make(chan struct{})} entered, allowReturn := make(chan context.Context, 1), make(chan struct{}) - r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (preparedFixture, error) { if req.RunID != "" || len(req.Input) != 0 { t.Error("run input reached preparation") } @@ -219,7 +216,7 @@ func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { gotSession <- s return s, nil } - r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) prepare := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) if err := r.Handle(t.Context(), prepare); err != nil { t.Fatal(err) @@ -276,7 +273,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { close(cancelEntered) return (<-lateSession).Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) ready := waitPreparationStatus(t, sender, "request", "ready", "") _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("input")})) @@ -313,7 +310,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { sender := &recSender{} created := make(chan *controlledPreparation, 2) - r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { p := &controlledPreparation{closed: make(chan struct{})} created <- p return p, nil @@ -351,7 +348,7 @@ func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { created := make(chan struct{}) p := &controlledPreparation{closed: make(chan struct{})} - r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { close(created) return p, nil }) @@ -369,18 +366,15 @@ 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" }, - "authoring": func(p *proto.PromptRequestPayload) { p.WorkspaceAuthoring = true }, + "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) (agent.Prepared, error) { + r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { t.Error("invalid preparation reached native factory") return nil, errors.New("invalid") }) diff --git a/apps/daemon/internal/dispatch/prepared_handoff.go b/apps/daemon/internal/dispatch/prepared_handoff.go index 8bdd11834..382db5ab5 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 @@ -111,10 +102,8 @@ func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, fai if release == nil { release = &preparedRelease{abort: make(chan struct{}), failure: failure, settled: make(chan struct{})} handoff.release = release - state.retain = false state.steeringClosed = true state.session = nil - r.clearInteractionRoutesLocked(state) } if abort && !release.aborted() { close(release.abort) @@ -272,6 +261,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() @@ -333,10 +327,6 @@ func (r *Router) forwardPreparedOutput(state *sessionState) { if drainOnly { continue } - switch env.Type { - case proto.TypePermissionRequest, proto.TypePermissionCancel, proto.TypePromptForUserChoice: - r.indexPermissionFrame(state, env) - } if err := r.sendSessionOutput(pumpCtx, state, env); err != nil { r.mu.Lock() if r.closed { @@ -412,16 +402,6 @@ func (r *Router) forwardPreparedTerminal(state *sessionState, failure string, te return r.sendSessionOutput(pumpCtx, state, done) } -func (r *Router) awaitPreparedRelease(ctx context.Context, handoff *preparedHandoff, release *preparedRelease, attempt *preparedReleaseAttempt) error { - if err := r.awaitPreparedNativeRelease(ctx, release, attempt); err != nil { - return err - } - r.mu.Lock() - err := handoff.outputErr - r.mu.Unlock() - return err -} - func (r *Router) awaitPreparedNativeRelease(ctx context.Context, release *preparedRelease, attempt *preparedReleaseAttempt) error { select { case <-attempt.done: diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 095ec5642..9e86b0168 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -77,7 +77,7 @@ func (s *blockingPreparedReceiptSender) matches(env proto.Envelope) bool { } func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { - for _, operation := range []string{"function", "permission", "choice", "steering"} { + for _, operation := range []string{"function", "steering"} { t.Run(operation, func(t *testing.T) { sender := &blockingPreparedReceiptSender{ recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", @@ -89,7 +89,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") @@ -97,14 +97,6 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { switch operation { case "function": mutation = mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: sender.deliveryID}) - case "permission": - session.out <- mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "permission"}) - waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePermissionRequest, "run") }, "permission request") - mutation = mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: sender.deliveryID, Approved: true}) - case "choice": - session.out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "ask"}) - waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") }, "choice request") - mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) case "steering": mutation = mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: sender.inputID, Input: proto.TextInput("continue")}) } @@ -125,28 +117,6 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { t.Fatal(err) } assertDecisionAck(t, sender.recSender, "late-function", false, "not_ready") - case "permission": - late := mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: "late-permission", Approved: true}) - if err := r.Handle(t.Context(), late); err != nil { - t.Fatal(err) - } - assertDecisionAck(t, sender.recSender, "late-permission", true, "") - newRequest := mustEnv(t, proto.TypePermissionDecision, "new-permission", proto.PermissionDecisionPayload{DeliveryID: "new-permission", Approved: true}) - if err := r.Handle(t.Context(), newRequest); err != nil { - t.Fatal(err) - } - assertDecisionAck(t, sender.recSender, "new-permission", false, "not_pending") - case "choice": - late := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "late-choice", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) - if err := r.Handle(t.Context(), late); err != nil { - t.Fatal(err) - } - assertDecisionAck(t, sender.recSender, "late-choice", true, "") - newRequest := mustEnv(t, proto.TypePromptForUserChoiceDecision, "new-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "new-choice", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) - if err := r.Handle(t.Context(), newRequest); err != nil { - t.Fatal(err) - } - assertDecisionAck(t, sender.recSender, "new-choice", false, "not_pending") case "steering": late := mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "late-steering", Input: proto.TextInput("late")}) if err := r.Handle(t.Context(), late); err != nil { @@ -187,17 +157,6 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { if session.functions.Load() != 1 { t.Fatalf("function native calls = %d, want 1", session.functions.Load()) } - case "permission": - if len(session.submissions()) != 1 { - t.Fatalf("permission native calls = %d, want 1", len(session.submissions())) - } - case "choice": - session.askMu.Lock() - calls := len(session.askCalls) - session.askMu.Unlock() - if calls != 1 { - t.Fatalf("choice native calls = %d, want 1", calls) - } case "steering": if session.steers.Load() != 1 { t.Fatalf("steering native calls = %d, want 1", session.steers.Load()) @@ -234,7 +193,7 @@ func assertReceiptBeforeDone(t *testing.T, sender *recSender, operation string) } func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { - for _, operation := range []string{"function", "permission", "choice", "steering"} { + for _, operation := range []string{"function", "steering"} { t.Run(operation, func(t *testing.T) { sender := &blockingPreparedReceiptSender{recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} defer close(sender.release) @@ -244,7 +203,7 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") @@ -252,14 +211,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { switch operation { case "function": mutation = mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: sender.deliveryID}) - case "permission": - session.out <- mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "permission"}) - waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePermissionRequest, "run") }, "permission request") - mutation = mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: sender.deliveryID, Approved: true}) - case "choice": - session.out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "ask"}) - waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") }, "choice request") - mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) case "steering": mutation = mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: sender.inputID, Input: proto.TextInput("continue")}) } @@ -302,7 +253,7 @@ func TestPreparedHandoffEarlyDonePublishesAfterStarted(t *testing.T) { return nil, ctx.Err() } } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) <-emitted if hasFrame(sender, proto.TypeDone, "run") || session.cancels() != 0 { diff --git a/apps/daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go index f70e4db7b..f366b649f 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -36,7 +36,7 @@ func TestPreparedHandoffDrainsBurstBeforeStartReturns(t *testing.T) { return nil, ctx.Err() } } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) select { case <-sent: @@ -118,7 +118,7 @@ func TestPreparedHandoffAbortBeforeStartAdmissionSkipsNativeStart(t *testing.T) return nil, errors.New("unexpected Start") } p.cancel = func(context.Context) error { return p.Close() } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender.recSender) select { case <-sender.entered: @@ -161,32 +161,20 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi p := &controlledPreparation{closed: make(chan struct{})} p.start = func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session.out = out - out <- mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "publication-permission"}) - out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "publication-choice"}) return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender.recSender) select { case <-sender.entered: case <-time.After(2 * time.Second): t.Fatal("started status did not reach publication boundary") } - waitFor(t, func() bool { - return hasFrame(sender.recSender, proto.TypePermissionRequest, "run") && hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") - }, "pre-publication interaction routes") - for _, mutation := range []proto.Envelope{ - mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "publication-function"}), - mustEnv(t, proto.TypePermissionDecision, "publication-permission", proto.PermissionDecisionPayload{DeliveryID: "publication-permission", Approved: true}), - mustEnv(t, proto.TypePromptForUserChoiceDecision, "publication-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "publication-choice", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}), - } { - if err := r.Handle(t.Context(), mutation); err != nil { - t.Fatal(err) - } + result := mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "publication-function"}) + if err := r.Handle(t.Context(), result); err != nil { + t.Fatal(err) } assertDecisionAck(t, sender.recSender, "publication-function", false, "not_ready") - assertDecisionAck(t, sender.recSender, "publication-permission", false, "not_ready") - assertDecisionAck(t, sender.recSender, "publication-choice", false, "not_ready") if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "publication-steering", Input: proto.TextInput("continue")})); err != nil { t.Fatal(err) } @@ -202,10 +190,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeWorkspaceReadResult || frames[len(frames)-1].DecodePayload(&readResult) != nil || readResult.ErrorCode != "resource_unavailable" { t.Fatalf("pre-publication workspace read = %+v", frames) } - session.askMu.Lock() - askCalls := len(session.askCalls) - session.askMu.Unlock() - if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 || len(session.submissions()) != 0 || askCalls != 0 { + if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 { t.Fatal("private Session accepted work before started publication") } duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")}) @@ -229,7 +214,7 @@ func TestPreparedHandoffUnsupportedFunctionReleasesOperationBarrier(t *testing.T session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) waitPreparationStatus(t, sender, "request", "started", "") result := mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "unsupported", Success: true, Content: functionResultContent("answer"), DeliveryID: "unsupported"}) @@ -256,7 +241,7 @@ func TestPreparedHandoffEarlyDoneStillAllowsExplicitAbort(t *testing.T) { return nil, context.Canceled } p.cancel = func(context.Context) error { unblock(); return nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) defer unblock() startCancellationPreparation(t, r, sender) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done release claim") @@ -280,7 +265,7 @@ func TestPreparedHandoffExpiresDuringStartedPublication(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-sender.entered time.Sleep(250 * time.Millisecond) @@ -320,7 +305,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done claim") unblock() diff --git a/apps/daemon/internal/dispatch/prompt.go b/apps/daemon/internal/dispatch/prompt.go deleted file mode 100644 index 2a8d45908..000000000 --- a/apps/daemon/internal/dispatch/prompt.go +++ /dev/null @@ -1,149 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelope) error { - r.log.InfoContext(callerCtx, "handlePromptRequest: decoding payload", "env_id", env.ID, "env_type", env.Type) - var req proto.PromptRequestPayload - if err := env.DecodePayload(&req); err != nil { - r.log.ErrorContext(callerCtx, "handlePromptRequest: decode failed", "env_id", env.ID, "err", err) - return fmt.Errorf("dispatch: decode prompt_request: %w", err) - } - // Envelope.ID is the run id; payload mirrors it but envelope wins. - runID := env.ID - if runID == "" { - runID = req.RunID - } - if runID == "" { - r.log.ErrorContext(callerCtx, "handlePromptRequest: missing run id") - return errors.New("dispatch: prompt_request missing run id (Envelope.ID and Payload.RunID both empty)") - } - req.RunID = runID - var err error - if req, err = r.localWorkspace.Configure(req); err != nil { - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if req.AgentKind == "" { - r.log.ErrorContext(callerCtx, "handlePromptRequest: missing agent_kind", "run_id", runID) - return errors.New("dispatch: prompt_request missing agent_kind") - } - if req.WorkspaceReadOnly { - err := errors.New("read-only preparation cannot accept a prompt") - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - caps, available := r.availableCapabilities(req.AgentKind) - if !available { - _, err := r.registry.Resolve(req.AgentKind) - if err == nil { - err = errors.New("engine is unavailable on this runtime") - } - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported() { - err := errors.New("engine does not support function tools") - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if err := validateExecutionEnvironment(req, caps); err != nil { - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - r.log.InfoContext(callerCtx, "handlePromptRequest: decoded", - "run_id", runID, "agent_kind", req.AgentKind, - "message_count", len(req.Input), - "has_agent_options", req.AgentOptions != nil, - "agent_session_id", req.AgentSessionID, - "agent_state_key", req.AgentStateKey) - - factory, err := r.registry.Resolve(req.AgentKind) - if err != nil { - r.log.ErrorContext(callerCtx, "handlePromptRequest: registry.Resolve failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) - // Synthesize error+done so the server-side stream closes - // cleanly instead of waiting for a done that never comes. - r.emitTerminalError(callerCtx, runID, fmt.Sprintf("unsupported agent_kind %q on this daemon", req.AgentKind)) - return err - } - r.log.InfoContext(callerCtx, "handlePromptRequest: factory resolved", "run_id", runID, "agent_kind", req.AgentKind) - - // Lock-protect duplicate-run check + insert so two prompt_requests - // with the same RunID can't both start sessions. - r.mu.Lock() - if r.closed { - r.mu.Unlock() - r.log.ErrorContext(callerCtx, "handlePromptRequest: router closed", "run_id", runID) - return ErrRouterClosed - } - if r.runtimePreparation != nil || r.workspaceWrite != nil || r.workspaceExport != nil { - r.mu.Unlock() - err := errors.New("local workspace has an unsettled write") - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if _, dup := r.sessions[runID]; dup { - r.mu.Unlock() - r.log.WarnContext(callerCtx, "ignoring duplicate prompt_request", "run_id", runID) - return nil - } - stateKey := sessionStateKey(req) - r.touchIdleLocked(stateKey) - sessionCtx, sessionCancel := context.WithCancel(context.Background()) - // Re-attach the inbound trace so every log under this run shows - // the same trace_id as the prompt_request that started it. - if carrier, ok := obslog.TraceFromContext(callerCtx); ok { - sessionCtx = obslog.WithTrace(sessionCtx, carrier) - } - out := make(chan proto.Envelope, 64) - state := &sessionState{ - runID: runID, - capabilities: caps, - environmentID: req.EnvironmentID(), - stateKey: stateKey, - out: out, - ctx: sessionCtx, - ctxCancel: sessionCancel, - pendingIDs: make(map[string]struct{}), - pendingAsks: make(map[string]struct{}), - traceparent: env.Trace, - retain: stateKey != "", - releaseOnCompletion: req.ReleaseOnCompletion, - } - r.sessions[runID] = state - r.mu.Unlock() - - r.log.InfoContext(callerCtx, "handlePromptRequest: calling factory", "run_id", runID) - req, err = r.localWorkspace.Prepare(sessionCtx, req) - var sess agent.Session - if err == nil { - sess, err = factory(sessionCtx, req, out) - } - if err != nil { - r.log.ErrorContext(callerCtx, "handlePromptRequest: factory call failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) - // Roll back the registration, cancel ctx, surface error+done - // so the server doesn't hang on a phantom run. - r.mu.Lock() - delete(r.sessions, runID) - r.mu.Unlock() - sessionCancel() - r.emitTerminalError(callerCtx, runID, fmt.Sprintf("agent factory failed: %v", err)) - return fmt.Errorf("dispatch: factory %q: %w", req.AgentKind, err) - } - r.log.InfoContext(callerCtx, "handlePromptRequest: session created, starting pump", "run_id", runID) - r.mu.Lock() - state.session = sess - r.mu.Unlock() - - r.shutdownWG.Add(1) - go r.pump(state) - return nil -} diff --git a/apps/daemon/internal/dispatch/receipt_order_test.go b/apps/daemon/internal/dispatch/receipt_order_test.go index 8fb22b4c4..898889a4b 100644 --- a/apps/daemon/internal/dispatch/receipt_order_test.go +++ b/apps/daemon/internal/dispatch/receipt_order_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -52,7 +50,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { registry := agent.NewRegistry() var session *fakeSession var calls atomic.Int32 - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, 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) @@ -61,7 +59,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { } return nil }}, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) if err != nil { t.Fatal(err) @@ -75,18 +73,20 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { t.Fatal(err) } } - handle(proto.TypePromptRequest, proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true}) + startRun(t, router, &sender.recSender, "ordered", proto.PromptRequestPayload{AgentKind: "codex"}) + 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") - if session.cancels() != 0 || len(sender.snapshot()) != 0 { - t.Fatal("native release or Done overtook receipt") + // 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() + 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) @@ -98,7 +98,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { handle(proto.TypePromptSteer, input) input.InputID = "new" handle(proto.TypePromptSteer, input) - frames = sender.snapshot() + 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) @@ -106,8 +106,8 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { } } release.Do(func() { close(sender.release) }) - waitFor(t, func() bool { return router.ActiveRuns() == 0 }, "durable completion") - frames := sender.snapshot() + 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()) } @@ -118,14 +118,14 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { } } -func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { +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 - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, 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) @@ -136,15 +136,13 @@ func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { } return nil }}, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) if err != nil { t.Fatal(err) } defer router.Shutdown(context.Background()) - if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "shutdown", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + startRun(t, router, &sender.recSender, "shutdown", proto.PromptRequestPayload{AgentKind: "codex"}) if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptSteer, "shutdown", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text")})); err != nil { t.Fatal(err) } @@ -152,7 +150,7 @@ func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { if phase == "receipt_send" { <-sender.entered session.out <- mustEnv(t, proto.TypeDone, "shutdown", proto.DonePayload{}) - waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown") }, "completion barrier") + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown") }, "receipt join") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() diff --git a/apps/daemon/internal/dispatch/receipt_shutdown_test.go b/apps/daemon/internal/dispatch/receipt_shutdown_test.go index 4455f59ac..e736bf867 100644 --- a/apps/daemon/internal/dispatch/receipt_shutdown_test.go +++ b/apps/daemon/internal/dispatch/receipt_shutdown_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -47,23 +45,21 @@ 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 - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, 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) } - if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "shutdown-terminal", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + startRun(t, router, &sender.recSender, "shutdown-terminal", proto.PromptRequestPayload{AgentKind: "codex"}) if err = router.Handle(context.Background(), mustEnv(t, 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") }, "completion barrier") + 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) diff --git a/apps/daemon/internal/dispatch/router.go b/apps/daemon/internal/dispatch/router.go index 90610b186..272f65f6b 100644 --- a/apps/daemon/internal/dispatch/router.go +++ b/apps/daemon/internal/dispatch/router.go @@ -1,8 +1,6 @@ // Package dispatch wires inbound WebSocket frames to the agent layer. -// It owns one Session per active RunID, a per-session pump goroutine -// that forwards the agent's events to the transport, and a -// permission_id → run_id index so permission_decision frames route -// back to the right session. +// It owns one Turn per active RunID and a per-Turn output goroutine that +// forwards the agent's events to the transport. // // Concurrency: Handle is safe for one goroutine (typically the read // loop). Each session runs its own goroutine. Internal state is @@ -37,11 +35,8 @@ type Router struct { admission sync.RWMutex suspension *proto.EnvironmentSuspendPayload mu sync.Mutex - sessions map[string]*sessionState // RunID → state - idle map[string]map[*sessionState]struct{} - permIndex map[string]string // permID → RunID - askIndex map[string]string // askID → RunID - applied map[string]appliedInteractionDecision + sessions map[string]*sessionState // RunID → state + applied map[string]appliedFunctionResult // RunID and call ID → applied result shutdownAttempt *shutdownAttempt shutdownCh chan struct{} // closed by Shutdown shutdownWG dispatchWork // waits for all pump goroutines @@ -58,39 +53,28 @@ type Router struct { localWorkspace *localworkspace.Binding } -type appliedInteractionDecision struct { - requestID string - kind string +type appliedFunctionResult struct { fingerprint [32]byte recordedAt time.Time } // 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 prompt_request'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 { - capabilities proto.AgentKindCapabilities - runID string - environmentID string - stateKey string - session agent.Session - out chan proto.Envelope - ctx context.Context - ctxCancel context.CancelFunc - pendingIDs map[string]struct{} - pendingAsks map[string]struct{} - traceparent string - idleTimer *time.Timer - idleLease uint64 - retain bool - releaseOnCompletion bool - steering map[string]steeringReceipt - steerBusy bool - steeringClosed bool - steeringDone chan struct{} - preparedHandoff *preparedHandoff + capabilities proto.AgentKindCapabilities + runID string + environmentID string + session agent.Turn + out chan proto.Envelope + ctx context.Context + traceparent string + steering map[string]steeringReceipt + steerBusy bool + steeringClosed bool + preparedHandoff *preparedHandoff } // Config is the constructor input. Registry and Sender are required; @@ -132,10 +116,7 @@ func New(cfg Config) (*Router, error) { sender: cfg.Sender, log: log, sessions: make(map[string]*sessionState), - idle: make(map[string]map[*sessionState]struct{}), - permIndex: make(map[string]string), - askIndex: make(map[string]string), - applied: make(map[string]appliedInteractionDecision), + applied: make(map[string]appliedFunctionResult), shutdownCh: make(chan struct{}), idleTimeout: idleTimeout, executors: make(map[string]*executorState), @@ -160,7 +141,7 @@ func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { r.mu.Unlock() return ErrRouterClosed } - if r.suspension != nil && env.Type != proto.TypeDeviceShutdown { + if r.suspension != nil { r.mu.Unlock() return ErrRouterQuiesced } @@ -183,20 +164,12 @@ func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { return r.handleExecutionStart(ctx, env) case proto.TypeExecutionRelease: return r.handleExecutionRelease(ctx, env) - case proto.TypePromptRequest: - return r.handlePromptRequest(ctx, env) case proto.TypePromptCancel: return r.handlePromptCancel(ctx, env) case proto.TypeFunctionResult: return r.handleFunctionResult(ctx, env) case proto.TypePromptSteer: return r.handlePromptSteer(ctx, env) - case proto.TypePermissionDecision: - return r.handlePermissionDecision(ctx, env) - case proto.TypePromptForUserChoiceDecision: - return r.handlePromptForUserChoiceDecision(ctx, env) - case proto.TypeDeviceShutdown: - return r.handleDeviceShutdown(ctx, env) default: // Unknown types are logged and dropped — keeps the daemon // forward-compatible with server-side additions. @@ -214,7 +187,7 @@ func adoptEnvelopeTrace(ctx context.Context, env proto.Envelope) context.Context return obslog.WithTrace(ctx, carrier) } } - ctx, _ = obslog.StartBackgroundTrace(ctx, "daemon.envelope") + ctx, _ = obslog.StartBackgroundTrace(ctx) return ctx } @@ -237,95 +210,10 @@ func (r *Router) drain(ch <-chan proto.Envelope) { } } -// cleanupSession removes the session from registry maps. Called from -// pump's defer so it runs exactly once. +// cleanupSession removes the session from registry maps. Called once +// when its prepared release settles. func (r *Router) cleanupSession(s *sessionState) { r.mu.Lock() delete(r.sessions, s.runID) - for permID := range s.pendingIDs { - delete(r.permIndex, permID) - } - for askID := range s.pendingAsks { - delete(r.askIndex, askID) - } - if !s.retain || s.session == nil || s.stateKey == "" || r.closed { - r.mu.Unlock() - return - } - states := r.idle[s.stateKey] - if states == nil { - states = make(map[*sessionState]struct{}) - r.idle[s.stateKey] = states - } - states[s] = struct{}{} - r.scheduleIdleLocked(s) - r.mu.Unlock() -} - -func sessionStateKey(req proto.PromptRequestPayload) string { - return req.AgentStateKey -} - -func (r *Router) touchIdleLocked(stateKey string) { - if stateKey == "" { - return - } - for state := range r.idle[stateKey] { - r.scheduleIdleLocked(state) - } -} - -func (r *Router) scheduleIdleLocked(state *sessionState) { - if state.idleTimer != nil { - state.idleTimer.Stop() - } - state.idleLease++ - lease := state.idleLease - state.idleTimer = time.AfterFunc(r.idleTimeout, func() { - r.expireIdle(state, lease) - }) -} - -func (r *Router) expireIdle(state *sessionState, lease uint64) { - r.mu.Lock() - states := r.idle[state.stateKey] - if _, ok := states[state]; !ok || state.idleLease != lease || r.suspension != nil { - r.mu.Unlock() - return - } - r.shutdownWG.Add(1) - defer r.shutdownWG.Done() - delete(states, state) - if len(states) == 0 { - delete(r.idle, state.stateKey) - } - state.retain = false r.mu.Unlock() - - state.ctxCancel() - if err := state.session.Cancel(context.Background()); err != nil { - r.log.Warn("idle session cancel failed", "run_id", state.runID, "state_key", state.stateKey, "err", err) - } -} - -// emitTerminalError synthesises error + done for a run that couldn't -// even be started. Stamps env.Trace from ctx so the gateway can -// attribute these frames to the same trace_id. -func (r *Router) emitTerminalError(ctx context.Context, runID, msg string) { - traceparent := "" - if carrier, ok := obslog.TraceFromContext(ctx); ok { - traceparent = carrier.String() - } - errEnv, err := proto.NewEnvelopeWithTrace(proto.TypeError, runID, proto.ErrorPayload{Error: msg}, traceparent) - if err == nil { - if sendErr := r.sender.Send(ctx, errEnv); sendErr != nil { - r.log.ErrorContext(ctx, "emit terminal error frame failed", "run_id", runID, "err", sendErr) - } - } - doneEnv, err := proto.NewEnvelopeWithTrace(proto.TypeDone, runID, proto.DonePayload{}, traceparent) - if err == nil { - if sendErr := r.sender.Send(ctx, doneEnv); sendErr != nil { - r.log.ErrorContext(ctx, "emit terminal done frame failed", "run_id", runID, "err", sendErr) - } - } } diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 1e9b0ff15..607e95535 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -60,13 +58,10 @@ func (s *recSender) typesFor(runID string) []string { // fakeSession is a controllable session. The test owns its out // channel and manipulates outbound traffic / cancel observability. type fakeSession struct { + runID string + input proto.MessageInput cancelCalls int - submitCalls []permCall - askCalls []askCall - submitErr error - askErr error - cancelMu, submitMu sync.Mutex - askMu sync.Mutex + cancelMu sync.Mutex closeOutOnCancel bool out chan<- proto.Envelope closeOutOnCancelMu sync.Once @@ -74,16 +69,6 @@ type fakeSession struct { ctx context.Context } -type permCall struct { - id string - decision proto.PermissionDecisionPayload -} - -type askCall struct { - id string - decision proto.PromptForUserChoiceDecisionPayload -} - func (s *fakeSession) CancellationOutcome() proto.DonePayload { return proto.DonePayload{} } func (s *fakeSession) Cancel(context.Context) error { @@ -101,68 +86,38 @@ func (s *fakeSession) Cancel(context.Context) error { return nil } -func (s *fakeSession) SubmitPermission(_ context.Context, permID string, dec proto.PermissionDecisionPayload) error { - s.submitMu.Lock() - s.submitCalls = append(s.submitCalls, permCall{id: permID, decision: dec}) - s.submitMu.Unlock() - return s.submitErr -} - -func (s *fakeSession) SubmitPromptForUserChoice(_ context.Context, askID string, dec proto.PromptForUserChoiceDecisionPayload) error { - s.askMu.Lock() - s.askCalls = append(s.askCalls, askCall{id: askID, decision: dec}) - s.askMu.Unlock() - return s.askErr -} - func (s *fakeSession) cancels() int { s.cancelMu.Lock() defer s.cancelMu.Unlock() return s.cancelCalls } -func (s *fakeSession) submissions() []permCall { - s.submitMu.Lock() - defer s.submitMu.Unlock() - out := make([]permCall, len(s.submitCalls)) - copy(out, s.submitCalls) - return out -} - // --------------------------------------------------------------------- // helpers // --------------------------------------------------------------------- -// newHarness builds a Router whose registry exposes a single -// fake_alpha factory that records inputs and exposes the in-flight -// session. +// harness is a Router whose fake_alpha kind starts a recorded fakeSession +// for each Turn. type harness struct { router *dispatch.Router sender *recSender reg *agent.Registry - gotReq chan proto.PromptRequestPayload gotSess chan *fakeSession } func newHarness(t *testing.T) *harness { - return newHarnessWithIdleTimeout(t, time.Hour) -} - -func newHarnessWithIdleTimeout(t *testing.T, idleTimeout time.Duration) *harness { t.Helper() h := &harness{ sender: &recSender{}, reg: agent.NewRegistry(), - gotReq: make(chan proto.PromptRequestPayload, 16), gotSess: make(chan *fakeSession, 16), } - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Permissions: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess := &fakeSession{out: out, ctx: ctx} - h.gotReq <- req + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + sess := &fakeSession{runID: runID, input: input, out: out} h.gotSess <- sess return sess, nil - }) - r, err := dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, IdleTimeout: idleTimeout}) + })) + r, err := dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender}) if err != nil { t.Fatalf("dispatch.New: %v", err) } @@ -170,58 +125,20 @@ func newHarnessWithIdleTimeout(t *testing.T, idleTimeout time.Duration) *harness return h } -func TestCompletedSessionCancelsAfterIdleTimeout(t *testing.T) { - h := newHarnessWithIdleTimeout(t, 40*time.Millisecond) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_idle", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", ConversationID: "conv-idle", AgentStateKey: "conv-idle/agent/fake_alpha", +// sessionExecutor runs each Turn through start on a disposable Executor. +func sessionExecutor(start func(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error)) agent.ExecutorFactory { + return preparationExecutorFixture(func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { + return &controlledPreparation{start: start}, nil }) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("Handle prompt_request: %v", err) - } - sess := <-h.gotSess - close(sess.out) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active run cleanup") - - select { - case <-sess.ctx.Done(): - t.Fatal("completed session cancelled before idle timeout") - case <-time.After(15 * time.Millisecond): - } - waitFor(t, func() bool { return sess.cancels() == 1 }, "idle session cancellation") } -func TestNewPromptResetsCompletedSessionIdleTimeout(t *testing.T) { - h := newHarnessWithIdleTimeout(t, 80*time.Millisecond) - defer h.router.Shutdown(context.Background()) - - stateKey := "conv-renew/agent/fake_alpha" - first := mustEnv(t, proto.TypePromptRequest, "run_first", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", ConversationID: "conv-renew", AgentStateKey: stateKey, - }) - if err := h.router.Handle(context.Background(), first); err != nil { - t.Fatalf("Handle first prompt: %v", err) - } - firstSession := <-h.gotSess - close(firstSession.out) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "first run cleanup") - time.Sleep(50 * time.Millisecond) - - second := mustEnv(t, proto.TypePromptRequest, "run_second", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", ConversationID: "conv-renew", AgentStateKey: stateKey, - }) - if err := h.router.Handle(context.Background(), second); err != nil { - t.Fatalf("Handle second prompt: %v", err) - } - secondSession := <-h.gotSess - - time.Sleep(45 * time.Millisecond) - if firstSession.cancels() != 0 { - t.Fatal("new prompt did not renew the completed session idle timeout") - } - close(secondSession.out) - waitFor(t, func() bool { return firstSession.cancels() == 1 }, "renewed idle session cancellation") +// startRun prepares a dedicated Executor for run, starts the Run with input +// "input" and waits until it accepts operations. +func startRun(t *testing.T, r *dispatch.Router, s *recSender, run string, config proto.PromptRequestPayload) { + t.Helper() + ready := executorAdmission(t, r, s, "prepare-"+run, noEnvironmentPreparation("session-"+run, config)) + startExecutorTurn(t, r, s, "prepare-"+run, run, ready) + waitFor(t, func() bool { return r.RunStartedForTest(run) }, "run "+run+" to accept operations") } func mustEnv(t *testing.T, typ, id string, payload any) proto.Envelope { @@ -237,22 +154,15 @@ func mustEnv(t *testing.T, typ, id string, payload any) proto.Envelope { // tests // --------------------------------------------------------------------- -func TestHandlePromptRequestInvokesFactoryAndForwardsOutput(t *testing.T) { +func TestExecutionStartRunsInputAndForwardsOutput(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_1", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", Input: proto.TextInput("hi"), ConversationID: "c1", - }) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("Handle prompt_request: %v", err) - } - - req := <-h.gotReq - if req.RunID != "run_1" || req.AgentKind != "fake_alpha" || *req.Input[0].Content[0].Text != "hi" { - t.Errorf("factory got %+v, want run_1/fake_alpha/hi", req) - } + startRun(t, h.router, h.sender, "run_1", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess + if sess.runID != "run_1" || len(sess.input) != 1 || *sess.input[0].Content[0].Text != "input" { + t.Errorf("Turn got run %q input %+v, want run_1/input", sess.runID, sess.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}) @@ -261,69 +171,70 @@ func TestHandlePromptRequestInvokesFactoryAndForwardsOutput(t *testing.T) { waitForTypes(t, h.sender, "run_1", []string{proto.TypeDelta, proto.TypeDone}) - // Pump should have removed the session. + // Settlement should have removed the Run. waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active runs to drop to 0") } -func TestHandlePromptRequestRejectsDuplicateRunID(t *testing.T) { +func TestExecutionStartRejectsDuplicateRunID(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("first Handle: %v", err) - } - <-h.gotReq // drain first + startRun(t, h.router, h.sender, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess - // Second prompt_request with same RunID should NOT spin up a second factory call. - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("duplicate Handle: %v", err) + // Another Executor must not start a second Turn for the same RunID. + second := executorAdmission(t, h.router, h.sender, "prepare-dup", noEnvironmentPreparation("session-dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + start := mustEnv(t, 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("factory invoked twice for duplicate run, second req=%+v", extra) + case extra := <-h.gotSess: + t.Fatalf("Turn started twice for duplicate run, second run=%s", extra.runID) case <-time.After(50 * time.Millisecond): } close(sess.out) } -func TestHandlePromptRequestUnsupportedKindEmitsErrorDone(t *testing.T) { +func TestExecutionPrepareRejectsUnknownKind(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_x", proto.PromptRequestPayload{AgentKind: "fake_beta"}) - err := h.router.Handle(context.Background(), env) - if !errors.Is(err, agent.ErrUnsupportedKind) { - t.Errorf("Handle unsupported = %v, want ErrUnsupportedKind", err) + env := mustEnv(t, 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) } - got := h.sender.typesFor("run_x") - want := []string{proto.TypeError, proto.TypeDone} - if !slices.Equal(got, want) { - t.Errorf("sender frames for run_x = %v, want %v", got, want) + 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 TestHandlePromptRequestMissingRunIDIsError(t *testing.T) { +func TestExecutionStartRequiresRunID(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err == nil { + ready := executorAdmission(t, h.router, h.sender, "prepare", noEnvironmentPreparation("session", 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()) - env := mustEnv(t, proto.TypePromptRequest, "run_2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "run_2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess sess.closeOutOnCancel = true @@ -344,234 +255,6 @@ func TestHandlePromptCancelUnknownRunIsNoop(t *testing.T) { } } -func TestPermissionRequestIsIndexedAndDecisionRoutes(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_p", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq - sess := <-h.gotSess - - // Session emits a permission_request; pump should index it. - permEnv := mustEnv(t, proto.TypePermissionRequest, "run_p", proto.PermissionRequestPayload{ - RequestID: "perm_abcd1234", Tool: "Bash", Title: "rm -rf /", - }) - sess.out <- permEnv - // Wait until sender records — indexing happens before send. - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "permission_request to be forwarded") - - dec := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-1", Approved: true}) - if err := h.router.Handle(context.Background(), dec); err != nil { - t.Fatalf("permission_decision: %v", err) - } - calls := sess.submissions() - if len(calls) != 1 || calls[0].id != "perm_abcd1234" || !calls[0].decision.Approved { - t.Errorf("submissions = %+v, want one approved perm_abcd1234", calls) - } - assertDecisionAck(t, h.sender, "delivery-perm-1", true, "") - retry := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-2", Approved: true}) - if err := h.router.Handle(context.Background(), retry); err != nil { - t.Fatalf("idempotent permission replay: %v", err) - } - if calls := sess.submissions(); len(calls) != 1 { - t.Fatalf("idempotent replay reached agent twice: %+v", calls) - } - assertDecisionAck(t, h.sender, "delivery-perm-2", true, "") - conflict := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-3", Approved: false}) - if err := h.router.Handle(context.Background(), conflict); err != nil { - t.Fatalf("conflicting permission replay: %v", err) - } - assertDecisionAck(t, h.sender, "delivery-perm-3", false, "decision_conflict") - - close(sess.out) -} - -func TestPermissionCancelDeindexes(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_p2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - _ = h.router.Handle(context.Background(), env) - <-h.gotReq - sess := <-h.gotSess - - sess.out <- mustEnv(t, proto.TypePermissionRequest, "run_p2", proto.PermissionRequestPayload{RequestID: "perm_xx", Tool: "Bash"}) - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "perm forwarded") - sess.out <- mustEnv(t, proto.TypePermissionCancel, "perm_xx", nil) - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 2 }, "perm_cancel forwarded") - - // A decision for the cancelled perm should be a no-op (session never sees it). - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePermissionDecision, "perm_xx", proto.PermissionDecisionPayload{DeliveryID: "delivery-cancelled"})); err != nil { - t.Fatalf("decision: %v", err) - } - if calls := sess.submissions(); len(calls) != 0 { - t.Errorf("expected zero submissions after cancel, got %+v", calls) - } - assertDecisionAck(t, h.sender, "delivery-cancelled", false, "not_pending") - - close(sess.out) -} - -func TestPermissionDecisionUnknownPermIsNoop(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePermissionDecision, "perm_unknown", proto.PermissionDecisionPayload{DeliveryID: "delivery-unknown-perm"})); err != nil { - t.Errorf("decision for unknown perm = %v, want nil", err) - } - assertDecisionAck(t, h.sender, "delivery-unknown-perm", false, "not_pending") -} - -func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_ask", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-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. - askEnv := 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", - }) - sess.out <- askEnv - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") - - dec := mustEnv(t, 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) - } - - close(sess.out) -} - -// TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown locks in the -// other cleanup branch: when the session returns ErrUnknownAsk (timer -// already consumed the entry), the router still drops the index so a -// retry doesn't loop into Submit again. -func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_ask_u", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq - sess := <-h.gotSess - sess.askErr = agent.ErrUnknownAsk - - askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask_u", proto.PromptForUserChoicePayload{ - AskID: "ask_xxxxxxxx", - Questions: []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "yes"}}}}, - ToolUseID: "toolu_y", - }) - sess.out <- askEnv - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") - - dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_xxxxxxxx", proto.PromptForUserChoiceDecisionPayload{ - DeliveryID: "delivery-ask-gone", 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) - } - - if got := h.router.AskIndexLenForTest(); got != 0 { - t.Errorf("askIndex len = %d, want 0 after ErrUnknownAsk", got) - } - if got := h.router.PendingAsksLenForTest("run_ask_u"); got != 0 { - t.Errorf("pendingAsks len = %d, want 0 after ErrUnknownAsk", got) - } - assertDecisionAck(t, h.sender, "delivery-ask-gone", false, "not_pending") - - close(sess.out) -} - -func TestPromptForUserChoiceDecisionKeepsIndexOnTransientAgentError(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_ask_retry", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq - sess := <-h.gotSess - sess.askErr = errors.New("temporary stdin failure") - - sess.out <- mustEnv(t, proto.TypePromptForUserChoice, "run_ask_retry", proto.PromptForUserChoicePayload{ - AskID: "ask_retry", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Question: "Retry?"}}, - }) - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") - - decision := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_retry", proto.PromptForUserChoiceDecisionPayload{ - DeliveryID: "delivery-ask-retry", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, - }) - if err := h.router.Handle(context.Background(), decision); err != nil { - t.Fatalf("first decision: %v", err) - } - assertDecisionAck(t, h.sender, "delivery-ask-retry", false, "runtime_error") - if got := h.router.AskIndexLenForTest(); got != 1 { - t.Fatalf("askIndex len = %d, want 1 after transient error", got) - } - if got := h.router.PendingAsksLenForTest("run_ask_retry"); got != 1 { - t.Fatalf("pendingAsks len = %d, want 1 after transient error", got) - } - - sess.askErr = nil - if err := h.router.Handle(context.Background(), decision); err != nil { - t.Fatalf("retry decision: %v", err) - } - assertDecisionAck(t, h.sender, "delivery-ask-retry", true, "") - if got := h.router.AskIndexLenForTest(); got != 0 { - t.Fatalf("askIndex len = %d, want 0 after successful retry", got) - } - close(sess.out) -} - -func TestPromptForUserChoiceDecisionUnknownAskIsNoop(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_unknown", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "delivery-unknown-ask"})); err != nil { - t.Errorf("decision for unknown ask = %v, want nil", err) - } - assertDecisionAck(t, h.sender, "delivery-unknown-ask", false, "not_pending") -} - func assertDecisionAck(t *testing.T, sender *recSender, deliveryID string, applied bool, errorCode string) { t.Helper() frames := sender.snapshot() @@ -593,30 +276,6 @@ func assertDecisionAck(t *testing.T, sender *recSender, deliveryID string, appli t.Fatalf("no decision ack for delivery %q in %+v", deliveryID, frames) } -func TestHandleDeviceShutdownCancelsAllSessions(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - for _, rid := range []string{"r1", "r2", "r3"} { - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, rid, proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatalf("start %s: %v", rid, err) - } - <-h.gotReq - } - sessions := make([]*fakeSession, 3) - for i := range sessions { - sessions[i] = <-h.gotSess - sessions[i].closeOutOnCancel = true - } - - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeDeviceShutdown, "", proto.DeviceShutdownPayload{Reason: "reap"})); err != nil { - t.Fatalf("device_shutdown: %v", err) - } - for _, s := range sessions { - waitFor(t, func() bool { return s.cancels() == 1 }, "each session.Cancel to fire") - } -} - func TestHandleUnknownTypeIsNoop(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) @@ -631,7 +290,7 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { if err := h.router.Shutdown(context.Background()); err != nil { t.Fatalf("Shutdown: %v", err) } - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "r", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeExecutionPrepare, "r", noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "fake_alpha"}))) if !errors.Is(err, dispatch.ErrRouterClosed) { t.Errorf("post-shutdown Handle = %v, want ErrRouterClosed", err) } @@ -640,10 +299,7 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { func TestShutdownWaitsForPumpDrain(t *testing.T) { h := newHarness(t) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "rs", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "rs", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess // Background: emit one frame then close out shortly after diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index 1ed576542..1c777f0fe 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -114,7 +114,7 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e } func (r *Router) runtimePreparationResourcesBusyLocked() bool { - if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.executors) != 0 { + if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.executors) != 0 { return true } for _, p := range r.preparations { diff --git a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go index 1b9954af5..f6bb85f2a 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -30,7 +29,7 @@ func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { } sender := &recSender{} var calls atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { calls.Add(1) return nil, errors.New("native factory must not be reached") }) diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index feb7fbf6e..7a8a31c70 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -33,7 +33,7 @@ func (s *capabilitiesTestSender) Send(ctx context.Context, env proto.Envelope) e func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string, string) { t.Helper() environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, t.TempDir()) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, t.TempDir(), t.TempDir()) if err != nil { t.Fatal(err) } @@ -169,7 +169,7 @@ func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { } func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { - for _, mode := range []string{"write", "export", "read", "run", "idle", "executor", "preparation"} { + for _, mode := range []string{"write", "export", "read", "run", "executor", "preparation"} { t.Run(mode, func(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) switch mode { @@ -181,8 +181,6 @@ func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { r.workspaceReads = map[string]struct{}{"read": {}} case "run": r.sessions["run"] = &sessionState{} - case "idle": - r.idle["state"] = map[*sessionState]struct{}{} case "executor": r.executors[session] = &executorState{} case "preparation": @@ -202,7 +200,6 @@ func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { r.workspaceExport = nil r.workspaceReads = nil clear(r.sessions) - clear(r.idle) clear(r.executors) clear(r.preparations) shutdownCapabilitiesRouter(t, r) diff --git a/apps/daemon/internal/dispatch/shutdown.go b/apps/daemon/internal/dispatch/shutdown.go index c40d4875e..b09de4c70 100644 --- a/apps/daemon/internal/dispatch/shutdown.go +++ b/apps/daemon/internal/dispatch/shutdown.go @@ -4,9 +4,6 @@ import ( "context" "errors" "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type shutdownAttempt struct { @@ -31,35 +28,16 @@ func (r *Router) Shutdown(ctx context.Context) error { } } - first := !r.closed - var victims []sessionCancellation - for _, state := range r.sessions { - state.retain = false - if state.preparedHandoff != nil { - victims = append(victims, r.sessionCancellationLocked(state, true)) - } else if first { - victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) - } - } - if first { + victims := r.sessionCancellationsLocked() + if !r.closed { r.closed = true if r.runtimePreparation != nil { r.runtimePreparation.cancel() } - for _, states := range r.idle { - for state := range states { - state.retain = false - if state.idleTimer != nil { - state.idleTimer.Stop() - } - victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) - } - } - r.idle = make(map[string]map[*sessionState]struct{}) // Prepared release claims exist before this signal can interrupt output. close(r.shutdownCh) } - preparations := r.closePendingPreparationsLocked() + r.closePendingPreparationsLocked() executors := r.closeIdleExecutorsLocked() attempt := &shutdownAttempt{done: make(chan struct{})} r.shutdownAttempt = attempt @@ -68,9 +46,6 @@ func (r *Router) Shutdown(ctx context.Context) error { r.mu.Unlock() r.closeIdleExecutors(executors) - for _, p := range preparations { - go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() - } go r.runShutdownAttempt(attempt, victims) return waitShutdown(ctx, attempt) } @@ -78,19 +53,10 @@ func (r *Router) Shutdown(ctx context.Context) error { func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionCancellation) { var releaseErr error for _, victim := range victims { - if victim.handoff != nil { - // The cleanup operation has its own fixed native deadline. The - // Shutdown caller's deadline only bounds its wait for this attempt. - if err := r.awaitPreparedNativeRelease(context.Background(), victim.release, victim.attempt); err != nil { - releaseErr = errors.Join(releaseErr, fmt.Errorf("dispatch: prepared run %s: %w", victim.runID, err)) - } - continue - } - victim.ctxCancel() - if victim.session != nil { - if err := victim.session.Cancel(context.Background()); err != nil { - r.log.Warn("session.Cancel failed", "run_id", victim.runID, "err", err) - } + // The cleanup operation has its own fixed native deadline. The + // Shutdown caller's deadline only bounds its wait for this attempt. + if err := r.awaitPreparedNativeRelease(context.Background(), victim.release, victim.attempt); err != nil { + releaseErr = errors.Join(releaseErr, fmt.Errorf("dispatch: prepared run %s: %w", victim.runID, err)) } } r.shutdownWG.Done() @@ -119,7 +85,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() @@ -141,75 +111,19 @@ func waitShutdown(ctx context.Context, attempt *shutdownAttempt) error { } } -func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) error { - var payload proto.DeviceShutdownPayload - _ = env.DecodePayload(&payload) // body optional - r.log.InfoContext(ctx, "device_shutdown received, cancelling runs", "reason", payload.Reason, "active_runs", r.ActiveRuns()) - r.mu.Lock() - if r.closed { - r.mu.Unlock() - return ErrRouterClosed - } - if r.runtimePreparation != nil { - r.runtimePreparation.cancel() - } - victims := make([]sessionCancellation, 0, len(r.sessions)) - for _, state := range r.sessions { - state.retain = false - victims = append(victims, r.sessionCancellationLocked(state, true)) - } - for _, states := range r.idle { - for state := range states { - state.retain = false - if state.idleTimer != nil { - state.idleTimer.Stop() - } - victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) - } - } - r.idle = make(map[string]map[*sessionState]struct{}) - preparations := r.closePendingPreparationsLocked() - executors := r.closeIdleExecutorsLocked() - r.mu.Unlock() - r.closeIdleExecutors(executors) - for _, p := range preparations { - go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() - } - r.cancelSessions(ctx, victims) - return nil -} - type sessionCancellation struct { - runID string - ctxCancel context.CancelFunc - session agent.Session - handoff *preparedHandoff - release *preparedRelease - attempt *preparedReleaseAttempt + runID string + release *preparedRelease + attempt *preparedReleaseAttempt } -func (r *Router) sessionCancellationLocked(state *sessionState, retry bool) sessionCancellation { - if state.preparedHandoff != nil { - release, attempt := r.claimPreparedReleaseLocked(state, true, "", retry) - return sessionCancellation{runID: state.runID, handoff: state.preparedHandoff, release: release, attempt: attempt} - } - return sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session} -} - -func (r *Router) cancelSessions(ctx context.Context, sessions []sessionCancellation) { - for _, session := range sessions { - if session.handoff != nil { - if err := r.awaitPreparedRelease(ctx, session.handoff, session.release, session.attempt); err != nil { - r.log.Warn("prepared session release failed", "run_id", session.runID, "err", err) - } - continue - } - session.ctxCancel() - if session.session == nil { - continue - } - if err := session.session.Cancel(ctx); err != nil { - r.log.Warn("session.Cancel failed", "run_id", session.runID, "err", err) - } +// 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, release: release, attempt: attempt}) } + return victims } diff --git a/apps/daemon/internal/dispatch/steering.go b/apps/daemon/internal/dispatch/steering.go index cd6acfcf2..97a08a399 100644 --- a/apps/daemon/internal/dispatch/steering.go +++ b/apps/daemon/internal/dispatch/steering.go @@ -98,12 +98,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 || (!state.releaseOnCompletion && state.preparedHandoff == nil) { - 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 } @@ -120,8 +115,6 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr ack.ErrorCode, ack.Error = "in_flight", "This input is awaiting an engine receipt." state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} state.steerBusy = true - finished := make(chan struct{}) - state.steeringDone = finished r.shutdownWG.Add(1) go func() { defer r.shutdownWG.Done() @@ -129,7 +122,6 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr defer func() { r.mu.Lock() state.steerBusy = false - close(finished) r.mu.Unlock() }() ctx, stop := r.shutdownContext(ctx) @@ -180,25 +172,3 @@ func (r *Router) shutdownContext(parent context.Context) (context.Context, conte }() return ctx, cancel } - -// Close admission before taking the last worker: its lifetime includes the -// receipt send, so a durable Done cannot close the gateway subscription first. -func (r *Router) finishSteering(state *sessionState) error { - r.mu.Lock() - state.steeringClosed = true - finished := state.steeringDone - r.mu.Unlock() - if finished == nil { - return nil - } - timer := time.NewTimer(steeringCallTimeout + steeringSendTimeout) - defer timer.Stop() - select { - case <-finished: - return nil - case <-r.shutdownCh: - return context.Canceled - case <-timer.C: - return context.DeadlineExceeded - } -} 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/steering_lifetime_test.go b/apps/daemon/internal/dispatch/steering_lifetime_test.go index c51b87b87..9a2419768 100644 --- a/apps/daemon/internal/dispatch/steering_lifetime_test.go +++ b/apps/daemon/internal/dispatch/steering_lifetime_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "sync/atomic" @@ -28,7 +26,7 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { var session *fakeSession var calls atomic.Int32 release := make(chan struct{}) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true} return &durableSteeringSession{steeringSession: &steeringSession{fakeSession: session}, phased: func(ctx context.Context, input proto.PromptSteerPayload, written func()) error { calls.Add(1) @@ -40,11 +38,9 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { return ctx.Err() } }}, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "durable", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + })) + startRun(t, h.router, h.sender, "durable", proto.PromptRequestPayload{AgentKind: "codex"}) + base := len(h.sender.snapshot()) input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("additional"), DurableReceipt: true} env := mustEnv(t, proto.TypePromptSteer, "durable", input) if err := handleSteeringAndWait(t, h, env); err != nil { @@ -54,7 +50,7 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { t.Fatalf("write phase: %+v", ack) } time.Sleep(11 * time.Second) - if len(h.sender.snapshot()) != 1 { + if len(h.sender.snapshot()) != base+1 { t.Fatal("native wait ended at transport deadline") } if err := handleSteeringAndWait(t, h, env); err != nil { @@ -64,12 +60,12 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { t.Fatalf("cached phase: %+v", ack) } close(release) - waitFor(t, func() bool { return len(h.sender.snapshot()) == 3 }, "native acceptance") + waitFor(t, func() bool { return len(h.sender.snapshot()) == base+3 }, "native acceptance") if ack := lastSteeringAck(t, h.sender, "durable", "extra"); !ack.Accepted || ack.Written { t.Fatalf("final phase: %+v", ack) } session.out <- mustEnv(t, proto.TypeDone, "durable", proto.DonePayload{}) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "completion") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "durable") }, "completion") frames := h.sender.snapshot() if calls.Load() != 1 || frames[len(frames)-1].Type != proto.TypeDone { t.Fatal("replayed input or incorrect completion order") @@ -82,7 +78,7 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) exited := make(chan struct{}) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &durableSteeringSession{steeringSession: &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}}, phased: func(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { defer close(exited) if phase == "written" { @@ -91,11 +87,10 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { <-ctx.Done() return ctx.Err() }}, nil - }) + })) ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "codex"}) + base := len(h.sender.snapshot()) if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text"), DurableReceipt: true})); err != nil { t.Fatal(err) } @@ -105,12 +100,12 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { case <-time.After(12 * time.Second): t.Fatal("blocked write was not bounded") } - waitFor(t, func() bool { return len(h.sender.snapshot()) == 1 }, "unknown receipt") + waitFor(t, func() bool { return len(h.sender.snapshot()) == base+1 }, "unknown receipt") if ack := lastSteeringAck(t, h.sender, "run", "one"); ack.Written || ack.Accepted || ack.ErrorCode != "outcome_unknown" { t.Fatalf("transport uncertainty: %+v", ack) } } else { - waitFor(t, func() bool { return len(h.sender.snapshot()) == 1 }, "written phase") + waitFor(t, func() bool { return len(h.sender.snapshot()) == base+1 }, "written phase") stopCtx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() if err := h.router.Shutdown(stopCtx); err != nil { @@ -125,32 +120,3 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { }) } } - -func TestDurableSteeringRequiresOptInAndAdapter(t *testing.T) { - for _, supported := range []bool{false, true} { - t.Run(map[bool]string{false: "old-adapter", true: "retained-run"}[supported], func(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - s := &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { t.Error("unexpected legacy call"); return nil }} - if !supported { - return s, nil - } - return &durableSteeringSession{steeringSession: s, phased: func(context.Context, proto.PromptSteerPayload, func()) error { - t.Error("unexpected phased call") - return nil - }}, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: !supported})); err != nil { - t.Fatal(err) - } - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text"), DurableReceipt: true})); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run", "one"); ack.ErrorCode != "unsupported" { - t.Fatalf("capability gate: %+v", ack) - } - }) - } -} diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go index c989afd7c..bf5b214e9 100644 --- a/apps/daemon/internal/dispatch/steering_test.go +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -33,7 +31,7 @@ func TestSteeringReceiptsAndRetries(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls, starts := 0, 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { starts++ return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, @@ -45,17 +43,15 @@ func TestSteeringReceiptsAndRetries(t *testing.T) { if input.InputID != "input-1" || *input.Input[0].Content[0].Text != "additional text" { t.Errorf("input lost: %+v", input) } - if len(h.sender.snapshot()) != 0 { + if hasFrame(h.sender, proto.TypePromptSteerAck, "run-1") { t.Error("ack sent before engine accepted input") } return engineError }, }, nil - }) + })) ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run-1", proto.PromptRequestPayload{AgentKind: "codex"}) input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("additional text")} env := mustEnv(t, proto.TypePromptSteer, "run-1", input) // An ack transport failure must not cause another native invocation. @@ -94,7 +90,7 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls := 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { @@ -105,11 +101,9 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { return nil }, }, nil - }) + })) ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run-1", proto.PromptRequestPayload{AgentKind: "codex"}) input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("extra")} env := mustEnv(t, proto.TypePromptSteer, "run-1", input) for _, expected := range []string{"not_ready", ""} { @@ -132,16 +126,14 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-1", nil)); err != nil { t.Fatal(err) } - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "cancel cleanup") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "run-1") }, "cancel cleanup") if err := handleSteeringAndWait(t, h, env); err != nil { t.Fatal(err) } if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "run_inactive" { t.Fatalf("inactive: %+v", ack) } - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run-2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) session := <-h.gotSess defer close(session.out) if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { @@ -172,7 +164,7 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { defer h.router.Shutdown(context.Background()) entered, release := make(chan struct{}), make(chan struct{}) defer close(release) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { @@ -181,12 +173,10 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { return agent.ErrSteeringRejected }, }, nil - }) + })) ctx := context.Background() for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "fake_alpha"}} { - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, run.id, proto.PromptRequestPayload{AgentKind: run.engine})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, run.id, proto.PromptRequestPayload{AgentKind: run.engine}) } other := <-h.gotSess other.closeOutOnCancel = true @@ -206,9 +196,7 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { t.Fatal(err) } - if other.cancels() != 1 { - t.Fatal("other run cancellation blocked") - } + waitFor(t, func() bool { return other.cancels() == 1 }, "other run cancellation") } func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { @@ -235,7 +223,7 @@ func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls := 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { @@ -243,11 +231,8 @@ func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { return nil }, }, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } + })) + startRun(t, h.router, h.sender, "run-1", proto.PromptRequestPayload{AgentKind: "codex"}) for i := range 257 { input := proto.PromptSteerPayload{InputID: fmt.Sprintf("input-%d", i), Input: proto.TextInput("extra")} if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { diff --git a/apps/daemon/internal/dispatch/suspend.go b/apps/daemon/internal/dispatch/suspend.go index 003c9ce2f..97a8cc6fc 100644 --- a/apps/daemon/internal/dispatch/suspend.go +++ b/apps/daemon/internal/dispatch/suspend.go @@ -31,7 +31,7 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa r.mu.Unlock() return ErrRouterQuiesced } - if r.runtimePreparation != nil || len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { + if r.runtimePreparation != nil || len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil { r.mu.Unlock() return ErrRouterBusy } @@ -41,14 +41,6 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa return ErrRouterBusy } } - for _, states := range r.idle { - for state := range states { - if state.environmentID != request.EnvironmentID || state.steerBusy || len(state.pendingIDs) != 0 || len(state.pendingAsks) != 0 { - r.mu.Unlock() - return ErrRouterBusy - } - } - } for _, owner := range r.executors { if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil || owner.environmentID != request.EnvironmentID { r.mu.Unlock() @@ -61,14 +53,6 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa p.timer.Stop() } } - for _, states := range r.idle { - for state := range states { - state.idleLease++ - if state.idleTimer != nil { - state.idleTimer.Stop() - } - } - } for _, owner := range r.executors { owner.idleLease++ if owner.timer != nil { @@ -104,10 +88,5 @@ func (r *Router) Resume(request proto.EnvironmentSuspendPayload, sender Sender) for _, owner := range r.executors { r.scheduleExecutorIdleLocked(owner) } - for _, states := range r.idle { - for state := range states { - r.scheduleIdleLocked(state) - } - } return nil } diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index 64049b954..054a031ed 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -15,11 +15,13 @@ type suspendSender func(context.Context, proto.Envelope) error func (s suspendSender) Send(ctx context.Context, env proto.Envelope) error { return s(ctx, env) } -type suspendedSession struct{ cancelled atomic.Int32 } +type suspendedExecutor struct{ closed atomic.Int32 } -func (s *suspendedSession) CancellationOutcome() proto.DonePayload { return proto.DonePayload{} } +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 (s *suspendedSession) Cancel(context.Context) error { s.cancelled.Add(1); return nil } +func (e *suspendedExecutor) Close(context.Context) error { e.closed.Add(1); return nil } func suspensionRouter(t *testing.T, sender Sender) *Router { t.Helper() @@ -37,14 +39,12 @@ 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() {}} }, - "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": {}} }, - "write": func(r *Router) { r.workspaceWrite = &workspaceUpload{} }, - "export": func(r *Router) { r.workspaceExport = &workspaceExport{} }, - "permission": func(r *Router) { r.permIndex["permission"] = "run" }, - "choice": func(r *Router) { r.askIndex["choice"] = "run" }, + "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": {}} }, + "write": func(r *Router) { r.workspaceWrite = &workspaceUpload{} }, + "export": func(r *Router) { r.workspaceExport = &workspaceExport{} }, } for name, setup := range cases { t.Run(name, func(t *testing.T) { @@ -90,7 +90,7 @@ func TestQuiesceDrainsPendingReceiptAndFencesConcurrentAdmission(t *testing.T) { } admitted := make(chan error, 1) go func() { - admitted <- r.Handle(context.Background(), proto.Envelope{Type: proto.TypePromptRequest, ID: "late"}) + admitted <- r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "late"}) }() select { case err := <-quiet: @@ -106,23 +106,23 @@ func TestQuiesceDrainsPendingReceiptAndFencesConcurrentAdmission(t *testing.T) { } } -func TestQuiescePreservesIdleOwnerAgainstExpiredTimerAndRequiresExactResume(t *testing.T) { +func TestQuiescePreservesIdleExecutorAgainstExpiredTimerAndRequiresExactResume(t *testing.T) { sender := suspendSender(func(context.Context, proto.Envelope) error { return nil }) r := suspensionRouter(t, sender) - session := &suspendedSession{} - state := &sessionState{runID: "run", environmentID: "env", stateKey: "state", session: session, ctxCancel: func() {}, retain: true} + native := &suspendedExecutor{} + owner := &executorState{id: "executor", sessionID: "session", environmentID: "env", native: native, cancel: func() {}} r.mu.Lock() - r.idle["state"] = map[*sessionState]struct{}{state: {}} - r.scheduleIdleLocked(state) - oldLease := state.idleLease + 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(), request); err != nil { t.Fatal(err) } - r.expireIdle(state, oldLease) - if session.cancelled.Load() != 0 { - t.Fatal("pre-snapshot timer killed retained owner") + r.expireIdleExecutor(owner, oldLease) + if native.closed.Load() != 0 { + t.Fatal("pre-snapshot timer closed retained owner") } wrong := request wrong.SuspendID = "obsolete" @@ -133,10 +133,10 @@ func TestQuiescePreservesIdleOwnerAgainstExpiredTimerAndRequiresExactResume(t *t t.Fatal(err) } r.mu.Lock() - newLease := state.idleLease + newLease := owner.idleLease r.mu.Unlock() - r.expireIdle(state, newLease) - if session.cancelled.Load() != 1 { + r.expireIdleExecutor(owner, newLease) + if native.closed.Load() != 1 { t.Fatal("normal idle expiration was not restored") } } @@ -164,7 +164,7 @@ func TestQuiesceDrainDeadlineCannotReopenAdmission(t *testing.T) { if err := r.Quiesce(ctx, proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"}); !errors.Is(err, context.Canceled) { t.Fatalf("quiesce=%v", err) } - if err := r.Handle(context.Background(), proto.Envelope{Type: proto.TypePromptRequest, ID: "late"}); !errors.Is(err, ErrRouterQuiesced) { + if err := r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "late"}); !errors.Is(err, ErrRouterQuiesced) { t.Fatalf("deadline reopened admission: %v", err) } r.shutdownWG.Done() diff --git a/apps/daemon/internal/dispatch/workspace_directory_test.go b/apps/daemon/internal/dispatch/workspace_directory_test.go index 89de922ed..1c584484f 100644 --- a/apps/daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/daemon/internal/dispatch/workspace_directory_test.go @@ -18,7 +18,7 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) for _, name := range []string{"file", "second"} { if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/dispatch/workspace_export.go b/apps/daemon/internal/dispatch/workspace_export.go index 86fd2cb43..490cbd463 100644 --- a/apps/daemon/internal/dispatch/workspace_export.go +++ b/apps/daemon/internal/dispatch/workspace_export.go @@ -48,7 +48,7 @@ func (r *Router) handleWorkspaceExport(ctx context.Context, env proto.Envelope) } _, code := r.workspaceResourceLocked(proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) p := r.preparations[request.Handle] - if u != nil || r.workspaceWrite != nil || !r.localWorkspace.CanExport() || code != "" || p == nil || !p.workspaceReadOnly { + if u != nil || r.workspaceWrite != nil || !r.localWorkspace.CanExport() || code != "" || p == nil || p.executor != nil { r.mu.Unlock() return r.sendWorkspaceExport(ctx, env.ID, proto.WorkspaceExportResultPayload{Outcome: "rejected", ErrorCode: "resource_unavailable"}) } diff --git a/apps/daemon/internal/dispatch/workspace_export_test.go b/apps/daemon/internal/dispatch/workspace_export_test.go index 90dba1e5e..dfbec11e8 100644 --- a/apps/daemon/internal/dispatch/workspace_export_test.go +++ b/apps/daemon/internal/dispatch/workspace_export_test.go @@ -47,7 +47,7 @@ func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto. f.Close() } environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) if err != nil { t.Fatal(err) } @@ -57,7 +57,7 @@ func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto. t.Fatal(err) } handle := uuid.NewString() - r.preparations[handle] = &preparationState{workspaceReadOnly: true, environmentID: environment, owns: true, ctx: context.Background(), deadline: time.Now().Add(time.Hour), status: proto.PreparationStatusPayload{State: "ready"}} + r.preparations[handle] = &preparationState{environmentID: environment, owns: true, ctx: context.Background(), deadline: time.Now().Add(time.Hour), status: proto.PreparationStatusPayload{State: "ready"}} t.Cleanup(func() { r.mu.Lock() delete(r.preparations, handle) diff --git a/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go b/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go deleted file mode 100644 index fa48438e5..000000000 --- a/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type workspaceFailureBoundary struct { - slog.Handler - once sync.Once - entered, resume chan struct{} -} - -func (h *workspaceFailureBoundary) Handle(ctx context.Context, record slog.Record) error { - h.once.Do(func() { close(h.entered); <-h.resume }) - return h.Handler.Handle(ctx, record) -} - -type workspaceCloseFunc struct { - agent.Prepared - close func() error -} - -func (p workspaceCloseFunc) Close() error { return p.close() } - -func TestReadConstructorFailureCannotPublishReleaseDuringCleanupRetry(t *testing.T) { - boundary := &workspaceFailureBoundary{Handler: slog.NewTextHandler(io.Discard, nil), entered: make(chan struct{}), resume: make(chan struct{})} - sender := make(workspaceStatusSender, 16) - r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: slog.New(boundary)} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - timer := time.NewTimer(time.Hour) - defer timer.Stop() - retryEntered, retryResume := make(chan struct{}), make(chan struct{}) - var resumeOnce sync.Once - defer resumeOnce.Do(func() { close(retryResume) }) - calls := 0 - resource := workspaceCloseFunc{close: func() error { - calls++ - if calls == 2 { - close(retryEntered) - <-retryResume - } - return errors.New("cleanup incomplete") - }} - p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, - ctx: ctx, cancel: cancel, timer: timer, deadline: time.Now().Add(time.Hour), - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "preparing"}} - r.shutdownWG.Add(1) - prepared := make(chan struct{}) - go func() { - r.prepareExecution(p, proto.PromptRequestPayload{}, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return resource, errors.New("construction failed") - }) - close(prepared) - }() - <-boundary.entered - r.releasePreparation(p, "released", "", true, true) - <-retryEntered - close(boundary.resume) - <-prepared - for len(sender) > 0 { - var status proto.PreparationStatusPayload - if (<-sender).DecodePayload(&status) == nil && status.State == "released" { - t.Error("constructor published release while retry cleanup was blocked") - } - } - resumeOnce.Do(func() { close(retryResume) }) - r.shutdownWG.Wait() - if !p.owns || p.busy || p.status.ErrorCode != "cleanup_unconfirmed" || calls != 2 { - t.Fatal("failed retry did not retain cleanup ownership") - } -} diff --git a/apps/daemon/internal/dispatch/workspace_preparation_status_test.go b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go index ab8b4b98e..d748b5bbe 100644 --- a/apps/daemon/internal/dispatch/workspace_preparation_status_test.go +++ b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go @@ -2,15 +2,10 @@ package dispatch import ( "context" - "errors" - "sync" - "sync/atomic" "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) type workspaceStatusSender chan proto.Envelope @@ -20,117 +15,23 @@ func (s workspaceStatusSender) Send(_ context.Context, envelope proto.Envelope) return nil } -type offlineWorkspaceStatusSender struct{} - -func (offlineWorkspaceStatusSender) Send(context.Context, proto.Envelope) error { - return errors.New("observer disconnected") -} - -type unsettledWorkspacePreparation struct { - agent.Prepared - calls atomic.Int32 - settled atomic.Bool -} - -func (p *unsettledWorkspacePreparation) Close() error { - // Bound a regression so a recursive retry cannot hang the test. - if p.calls.Add(1) >= 100 || p.settled.Load() { - return nil - } - return errors.New("cleanup incomplete") -} - -func TestReadPreparationOfflineStatusDoesNotRetryCleanup(t *testing.T) { - prepared := &unsettledWorkspacePreparation{} +func TestReadPreparationRetryCannotPublishStaleStatus(t *testing.T) { + sender := make(workspaceStatusSender, 4) ctx, cancel := context.WithCancel(context.Background()) defer cancel() timer := time.NewTimer(time.Hour) defer timer.Stop() - r := &Router{sender: offlineWorkspaceStatusSender{}, shutdownCh: make(chan struct{}), log: obslog.Bg()} - p := &preparationState{workspaceReadOnly: true, owns: true, prepared: prepared, - ctx: ctx, cancel: cancel, timer: timer, - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "ready"}} - for attempt := int32(1); attempt <= 2; attempt++ { - r.releasePreparation(p, "released", "", true, true) - r.shutdownWG.Wait() - if got := prepared.calls.Load(); got != attempt { - t.Fatalf("explicit release %d caused %d cleanup attempts", attempt, got) - } - if !p.owns || p.busy || p.prepared != prepared || p.status.ErrorCode != "cleanup_unconfirmed" { - t.Fatal("unconfirmed cleanup lost its resource ownership") - } - } - prepared.settled.Store(true) - r.releasePreparation(p, "released", "", true, true) - r.shutdownWG.Wait() - if prepared.calls.Load() != 3 || p.owns || p.prepared != nil || p.status.State != "released" { - t.Fatal("explicit retry did not settle resource ownership") - } -} - -func TestReadPreparationRetryCannotPublishStaleRelease(t *testing.T) { - sender := make(workspaceStatusSender, 4) r := &Router{sender: sender, shutdownCh: make(chan struct{})} - p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 2, State: "released"}} - // A prepare retry captures this snapshot while Close is still running. + p := &preparationState{owns: true, ctx: ctx, cancel: cancel, timer: timer, + status: proto.PreparationStatusPayload{Handle: "reader", Revision: 2, State: "ready"}} + // A prepare retry captures this snapshot before the release settles. snapshot := p.status - // Close fails before the retry reaches publication. - p.busy = false - p.status = proto.PreparationStatusPayload{Handle: "reader", Revision: 3, State: "failed", ErrorCode: "cleanup_unconfirmed"} - r.publishPreparation(p, snapshot) - r.shutdownWG.Wait() - if len(sender) != 0 { - t.Fatal("stale release success escaped after failed Close") - } - r.publishPreparation(p, p.status) + r.releasePreparation(p, "released", "", true) r.shutdownWG.Wait() - var failed proto.PreparationStatusPayload - if len(sender) != 1 || (<-sender).DecodePayload(&failed) != nil || failed.State != "failed" { - t.Fatal("confirmed cleanup failure was suppressed") - } -} - -// Local read-only preparation uses no native factory. Keep the shared close -// settlement regression at its owner boundary instead of a retired remote fixture. -type blockingWorkspacePreparation struct { - agent.Prepared - entered, release chan struct{} -} - -func (p *blockingWorkspacePreparation) Close() error { - close(p.entered) - <-p.release - return nil -} - -func TestReadPreparationReleaseWaitsForClose(t *testing.T) { - sender := make(workspaceStatusSender, 4) - prepared := &blockingWorkspacePreparation{entered: make(chan struct{}), release: make(chan struct{})} - var release sync.Once - defer release.Do(func() { close(prepared.release) }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - timer := time.NewTimer(time.Hour) - defer timer.Stop() - r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: obslog.Bg()} - p := &preparationState{workspaceReadOnly: true, owns: true, prepared: prepared, - ctx: ctx, cancel: cancel, timer: timer, - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "ready"}} - r.releasePreparation(p, "released", "", true, true) - select { - case <-prepared.entered: - case <-time.After(time.Second): - t.Fatal("close did not start") - } - r.publishPreparation(p, p.status) - if len(sender) != 0 || !p.owns { - t.Fatal("release acknowledged before close settled") - } - release.Do(func() { close(prepared.release) }) + r.publishPreparation(p, snapshot) r.shutdownWG.Wait() - var status proto.PreparationStatusPayload - if p.owns || len(sender) != 1 || (<-sender).DecodePayload(&status) != nil || status.State != "released" { - t.Fatal("settled close did not release ownership and publish status") + var released proto.PreparationStatusPayload + if p.owns || len(sender) != 1 || (<-sender).DecodePayload(&released) != nil || released.State != "released" { + t.Fatal("stale ready snapshot escaped or settled release was not published") } } diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go index 4fd1111a5..7511a84c5 100644 --- a/apps/daemon/internal/dispatch/workspace_read.go +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -67,7 +67,6 @@ func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (an !p.owns || p.busy || p.ctx.Err() != nil || !time.Now().Before(p.deadline) { return nil, "resource_unavailable" } - resource = p.prepared if p.executor != nil { resource = p.executor.native } diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index cd4722a6a..7aa41f555 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -58,7 +58,7 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e } return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("write_capacity")) } - if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.workspaceReads) != 0 { + if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.workspaceReads) != 0 { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } diff --git a/apps/daemon/internal/dispatch/workspace_write_test.go b/apps/daemon/internal/dispatch/workspace_write_test.go index e6e710fe9..7ecab2792 100644 --- a/apps/daemon/internal/dispatch/workspace_write_test.go +++ b/apps/daemon/internal/dispatch/workspace_write_test.go @@ -20,7 +20,7 @@ func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.Worksp t.Helper() workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index c56610270..0ead8710a 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -3,11 +3,9 @@ package localworkspace import ( "errors" "os" - "path/filepath" "strings" "sync" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -26,14 +24,6 @@ type Binding struct { capabilityRoot string } -func New(environment, session, workspace string) (*Binding, error) { - root, err := paths.Root() - if err != nil { - return nil, err - } - return newNativeBinding(environment, session, workspace, filepath.Join(root, "capabilities")) -} - // NewWithCapabilityDirectory freezes paths selected by the Runtime operator. func NewWithCapabilityDirectory(environment, session, workspace, directory string) (*Binding, error) { return newNativeBinding(environment, session, workspace, directory) @@ -69,8 +59,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.WorkspaceAuthoring || !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 372cc8ec7..b23ea8c0f 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -20,13 +20,12 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { t.Setenv("OAC_RUNTIME_HOME", private) root := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - b, err := New(environment, session, root) + b, err := NewWithCapabilityDirectory(environment, session, root, t.TempDir()) if err != nil { t.Fatal(err) } 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,10 +39,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 }, - "product authoring": func(r *proto.PromptRequestPayload) { r.WorkspaceAuthoring = 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 @@ -97,14 +94,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/capability_preparation_test.go b/apps/daemon/internal/localworkspace/capability_preparation_test.go index 0d226d55e..dc061b085 100644 --- a/apps/daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/daemon/internal/localworkspace/capability_preparation_test.go @@ -40,12 +40,11 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { t.Fatalf("first preparation: %v", err) } writeSourceSkill(t, source, "second") - reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace) + reconnect, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.capabilityRoot) if err != nil { t.Fatal(err) } reconnect.networkAccess = b.networkAccess - reconnect.capabilityRoot = b.capabilityRoot again, err := reconnect.Prepare(t.Context(), configured) if err != nil || len(again.LocalEnvironment.Skills) != 1 { t.Fatalf("reconnection: %v", err) @@ -206,11 +205,11 @@ func TestPreparationFreezesToolOnlyEnvironmentAcrossReconnect(t *testing.T) { if err := os.WriteFile(source, []byte(`{"LOCAL_ONLY":"changed"}`), 0600); err != nil { t.Fatal(err) } - reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace) + reconnect, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.capabilityRoot) if err != nil { t.Fatal(err) } - reconnect.networkAccess, reconnect.capabilityRoot = b.networkAccess, b.capabilityRoot + reconnect.networkAccess = b.networkAccess if _, err := reconnect.Prepare(t.Context(), configured); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go index 1c8164fef..2db03a780 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -36,9 +36,6 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { return agentcapabilities.ErrInvalid } - if input.Network != "enabled" && input.Network != "disabled" { - return agentcapabilities.ErrInvalid - } directory, err := b.initializationCWD(input.CWD) if err != nil { return err 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/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go index 19ed6e18d..e16e46787 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -74,7 +74,7 @@ func TestRuntimeInitializationPackageTargets(t *testing.T) { } packages, _ := PackageDirectory() for _, action := range []string{"npm", "python"} { - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Packages: []string{"name with spaces", "second"}}); err != nil { t.Fatal(action, err) } raw, err := os.ReadFile(receipt) @@ -242,7 +242,7 @@ func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { t.Fatal(err) } - err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) + err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) if err != nil { t.Fatal(err) } @@ -261,13 +261,13 @@ func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { t.Fatal(err) } - for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { + for _, input := range []proto.RuntimeInitialization{{Action: "setup", Command: "true"}, {Action: "npm", Packages: []string{"valid"}}, {Action: "python", Packages: []string{"valid"}}} { var failed *InitializationFailure if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { t.Fatal("missing dependency was not explicit", input.Action, err) } } - for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { + for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Packages: []string{"--unsafe"}}} { if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { t.Fatal("invalid request accepted", input.Action) } diff --git a/apps/daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go index fb4bcec94..accdd85c6 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 "test" -// and "prod" servers can be paired in parallel without colliding. +// 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. @@ -8,7 +8,6 @@ package paths import ( "fmt" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "os" "path/filepath" "regexp" @@ -51,8 +50,7 @@ func Root() (string, error) { return filepath.Join(home, ".oac"), nil } -// ProfileDir returns ~/.oac/daemon/. NOT created; -// use EnsureProfileDir. +// ProfileDir returns ~/.oac/daemon/. It is not created here. func ProfileDir(profile string) (string, error) { if err := ValidateProfile(profile); err != nil { return "", err @@ -64,19 +62,6 @@ func ProfileDir(profile string) (string, error) { return filepath.Join(root, "daemon", profile), nil } -// EnsureProfileDir mkdirs the profile dir at mode 0o700 and returns -// its path. Idempotent. -func EnsureProfileDir(profile string) (string, error) { - dir, err := ProfileDir(profile) - if err != nil { - return "", err - } - if err := runtimefs.EnsurePrivateDir(dir); err != nil { - return "", fmt.Errorf("create profile dir %s: %w", dir, err) - } - return dir, nil -} - // AuthFile returns the absolute path to auth.json for a profile. func AuthFile(profile string) (string, error) { dir, err := ProfileDir(profile) @@ -103,12 +88,3 @@ func LogFile(profile string) (string, error) { } return filepath.Join(dir, "connect.log"), nil } - -// SessionsFile returns the absolute path to sessions.json. -func SessionsFile(profile string) (string, error) { - dir, err := ProfileDir(profile) - if err != nil { - return "", err - } - return filepath.Join(dir, "sessions.json"), nil -} diff --git a/apps/daemon/internal/paths/paths_test.go b/apps/daemon/internal/paths/paths_test.go index fc151033e..cd6fc605f 100644 --- a/apps/daemon/internal/paths/paths_test.go +++ b/apps/daemon/internal/paths/paths_test.go @@ -4,7 +4,6 @@ import ( "errors" "os" "path/filepath" - "runtime" "strings" "testing" @@ -76,10 +75,9 @@ func TestProfileDirAndFiles(t *testing.T) { } cases := map[string]func(string) (string, error){ - "auth.json": paths.AuthFile, - "connect.pid": paths.PIDFile, - "connect.log": paths.LogFile, - "sessions.json": paths.SessionsFile, + "auth.json": paths.AuthFile, + "connect.pid": paths.PIDFile, + "connect.log": paths.LogFile, } for filename, fn := range cases { got, err := fn("test") @@ -93,33 +91,6 @@ func TestProfileDirAndFiles(t *testing.T) { } } -func TestEnsureProfileDirCreates0700(t *testing.T) { - _ = withTempHome(t) - dir, err := paths.EnsureProfileDir("default") - if err != nil { - t.Fatalf("EnsureProfileDir: %v", err) - } - info, err := os.Stat(dir) - if err != nil { - t.Fatalf("stat after EnsureProfileDir: %v", err) - } - if !info.IsDir() { - t.Fatalf("EnsureProfileDir returned %q which is not a directory", dir) - } - if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o700 { - t.Errorf("EnsureProfileDir mode = %o, want 0700", mode) - } - - // Idempotent (mkdir -p semantics). - dir2, err := paths.EnsureProfileDir("default") - if err != nil { - t.Fatalf("EnsureProfileDir (second call): %v", err) - } - if dir2 != dir { - t.Fatalf("EnsureProfileDir second call returned %q, want %q", dir2, dir) - } -} - func TestInvalidProfileShortCircuits(t *testing.T) { _ = withTempHome(t) if _, err := paths.ProfileDir("bad/profile"); err == nil { @@ -128,9 +99,6 @@ func TestInvalidProfileShortCircuits(t *testing.T) { if _, err := paths.AuthFile("bad/profile"); err == nil { t.Fatal("AuthFile accepted invalid profile name") } - if _, err := paths.EnsureProfileDir("bad/profile"); err == nil { - t.Fatal("EnsureProfileDir accepted invalid profile name") - } } func TestRootRejectsRelativeOverride(t *testing.T) { diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go index bf1bf1f50..57e15b3aa 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 stamped at pair time. 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 13d5e2390..163de36cb 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 328737c23..80b93e394 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -157,10 +157,7 @@ func connectRuntime(t *testing.T, peer *corePeer, setupErr error) *runtimeSide { peer.accept(t) rt := &runtimeSide{conn: conn, executor: &controlledExecutor{turn: make(chan *controlledTurn, 1)}, stopped: make(chan struct{})} kinds := agent.NewRegistry() - kinds.RegisterKind(proto.SupportedAgentKind{Kind: prototest.HarnessKind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, - harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("prepared execution must not use prompt_request") - }) + kinds.RegisterKind(proto.SupportedAgentKind{Kind: prototest.HarnessKind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}) 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 8ecc4dbaf..0a08375ae 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -37,10 +37,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() @@ -163,9 +163,7 @@ func run() error { h := &harness{history: map[string]string{}} registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture_harness", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, - })}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("fixture execution requires an Executor") - }) + })}, harnessconfig.Configuration{}) registry.RegisterExecutor("fixture_harness", h.prepare) sink := &sender{encoder: json.NewEncoder(os.Stdout)} router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sink, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 64bb19dd3..3addd7584 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -348,7 +348,7 @@ Every resource list, the Session log and the project list share one grammar: - **ListToolbar**: on project-scoped lists the project filter first, then the SearchField (280px, search icon, Paper with the control ring), then any further filters (segmented status or order, selects); the count sits on the right in 12.5px Pencil ("12 total", "3 of 12", "40 loaded" when more exist). - **Project column**: shown only while All projects is selected, right after the name; archived projects are muted. - **NameCell**: the first column. The name at 500 weight (a link that turns indigo on hover when the row opens a detail page; a muted fallback such as "Untitled" when the resource has no name) with the compact ID underneath in 11.5px mono. The ID's copy button appears on row hover or focus; the full ID lives in its tooltip. -- **Creator column**: the last column before the actions, headed "Creator" with a help tip. It shows the creating key's name (its prefix when unnamed) with a small "Revoked" flag for revoked keys, "Admin copy" in Graphite for an asset Core records as an administrator copy, "Unknown" in Graphite when Core has no record, and "—" while loading or when the lookup failed. +- **Creator column**: the last column before the actions, headed "Creator" with a help tip. It shows the creating key's name (its prefix when unnamed) with a small "Revoked" flag for revoked keys, "Unknown" in Graphite when Core has no record, and "—" while loading or when the lookup failed. - **RowActions**: text actions right-aligned at the end of the row, 16px apart, ending with Delete (red on hover). A row click opens the detail page; action clicks do not. - **Partial failure**: when some projects fail to load, one red line names them above the table; the other projects still show. - **Empty state**: an unframed, centered block with an optional 24px outline icon, a clear title, a visible short explanation and a relevant action. First-use states explain how data arrives; filtered states offer Clear search; failed reads retain their error and retry. Empty Overview activity links to Projects and keys for API onboarding. @@ -367,8 +367,8 @@ Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header - **ConfirmDialog**: the one grammar for destructive actions. The body states what will be deleted and its consequences; the footer holds Cancel (outline) and the confirm button (danger), whose label changes while busy. Core's reason for a rejection, or an uncertain-outcome warning, appears in red inside the dialog. The Skill page's delete dialogs follow the same grammar; deleting a whole Skill also requires typing its name. Archiving a project says in bold that it can't be undone, then how many active keys it revokes (the project read's count, or more when its loaded key list shows more) and that assets and accepted work stay; with active keys it too requires typing the project's name, shown in mono with its inner spaces kept (surrounding spaces are forgiven, Unicode compared in NFC). While the project list is read again Archive waits; if that read failed, a red line says the count may be out of date and Archive stays disabled. - **Key dialogs**: name fields carry their rules in a help tip and their problem in red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. - **Executor credential dialog** (640px): the shown-once notice, then a prompt to save the JSON privately before Done. Download credential file is primary; Copy credential is secondary. Installation commands are not repeated here. Done forgets the credential; closing preserves it in the pending card. The native installer reads the unchanged JSON file: its absolute path is entered during interactive installation or passed with `--credential-file`; tokens never enter command arguments. -- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. For Docker, explain that membership in the docker group is root-equivalent. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to System or the Overview. -- **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images (and, for microsandbox, keeps its image store and data). The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. +- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to System or the Overview. +- **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images; the uninstaller prints what it keeps. The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. - **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox its own microVM), root-equivalent access (the node's account joins the docker group) and limited use (trusted workloads, or hosts without KVM). The footer holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; closing or Escape keeps microsandbox too. - **Edit node**: the name, then the sandbox limit with one 12px Graphite line under it once the node's heartbeat has the host's CPUs and memory: the host, each sandbox's size and at most how many fit. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then curl and Python (with the pinned SDK), each listing the project's Agents and creating a Session with a first message (`environment`, an inline `agent` with `model: ""`, and `input`). A copy the clipboard refuses selects the sample and says so in red underneath. One Graphite line says to put a model the model provider serves in place of ``, and that running an Agent needs a model provider: in each request, saved on the Agent, or the deployment default. An active project's page shows the same samples as a section without any key: the Shell block exports a quoted placeholder, and a Graphite line above the samples says to use a key issued for this project, shown only once at issuance. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without a public address only a note to set one shows. Before the installation is read, a skeleton holds the first sample's place. @@ -399,16 +399,16 @@ On Overview and Session log, failed reads that leave a section unavailable repla A failed action whose outcome needs a decision (a sandbox change with no answer, a timeout or a 5xx) opens an error dialog with the reason and the next step as its primary button. Failed refreshes and project reads also raise an error toast; other failed actions, Core's clear refusal of a sandbox change among them, are reported there with the reason. A refusal leaves the page usable as it was. Errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline ring, 8px corners, 12.5px Graphite) state bounded aggregation. Standing warnings that need action use an amber-tinted line at the top of the page body. On Nodes, this names nodes still bound to an old Core address; each of those nodes' status reads Old address (amber dot) with "Remove and add again" under it in 12px Graphite. Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key shown once, a destructive consequence) stay visible in body text. -A local-only installation has the same amber notice on Overview, Nodes and System: other machines cannot connect, followed by Core's configuration path and apply command as copyable values. If Core has no configuration snapshot, state that those instructions are unavailable; never fill in a path or command. Add node is disabled with its reason beside the action, and Getting started leaves its first step to do with the address fix visible. A pending or failed installation read cannot complete that step; a failed read shows Unknown and Retry. +A local-only installation has the same amber notice on Overview, Nodes and System: other machines cannot connect, followed by Review the public address, which leads to System. Add node is disabled with its reason beside the action, and Getting started leaves its first step to do with the address fix visible. A pending or failed installation read cannot complete that step; a failed read shows Unknown and Retry. ### Onboarding -Signing in and the console tour share one frame: a dark stage on the left (always dark, whatever the theme) and the task panel on the right, which follows the theme. The stage is the product's one authored moment: a flickering indigo dot grid under slow light rays (Magic UI's flickering grid and light rays), Core as the OpenAgentCore mark on a tile with a travelling border beam, and two orbits of Agents, Sessions, Skills, Vaults, files, templates and machines around it; the OpenAgentCore mark is itself nodes on a ring. Brand copy sits bottom-left in solid ink; it is a paragraph, not a heading, because the panel's title names the task. Signing in asks for one thing, the deployment's Core key, in a single password field; the default key location and a copyable read command stay visible beneath it, with a reminder to substitute a custom installation directory. The key’s authority stays in a help tip. A refused key, too many attempts or an unavailable console is an error beside the field. Signing in opens the console on the Overview. The optional tour has three chapters — Monitor, Resources, Platform — whose stage shows a real dark screenshot of those pages, tilted towards the panel; it takes the place of the console until its last button, Skip or Escape, and then returns the focus to the control that opened it. Entering the console or the tour, and leaving the tour, happen inside a View Transition: the old page dissolves forward and the new one is revealed in a circle growing from the pressed button. With reduced motion the orbits hold their places, the grid is a still frame and no transition runs. +Signing in and the console tour share one frame: a dark stage on the left (always dark, whatever the theme) and the task panel on the right, which follows the theme. The stage is the product's one authored moment: a flickering indigo dot grid under slow light rays (Magic UI's flickering grid and light rays), Core as the OpenAgentCore mark on a tile with a travelling border beam, and two orbits of Agents, Sessions, Skills, Vaults, files, templates and machines around it; the OpenAgentCore mark is itself nodes on a ring. Brand copy sits bottom-left in solid ink; it is a paragraph, not a heading, because the panel's title names the task. Signing in asks for one thing, the deployment's Core key, in a single password field; a copyable Docker Compose command to read the key stays visible beneath it, with a reminder to substitute a custom installation directory. The key’s authority stays in a help tip. A refused key, too many attempts or an unavailable console is an error beside the field. Signing in opens the console on the Overview. The optional tour has three chapters — Monitor, Resources, Platform — whose stage shows a real dark screenshot of those pages, tilted towards the panel; it takes the place of the console until its last button, Skip or Escape, and then returns the focus to the control that opened it. Entering the console or the tour, and leaving the tour, happen inside a View Transition: the old page dissolves forward and the new one is revealed in a circle growing from the pressed button. With reduced motion the orbits hold their places, the grid is a still frame and no transition runs. ### Getting started The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open System; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open System brings the Default model provider section to the top of the page body and focuses the default harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds the config file and apply command as copyable values. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in System, which leads to System. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Configuration generations A single rollout row opens a details dialog for Core's target generation, previous-generation sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or while the independent reset is active. Settled is preparation state, not proof that all nodes are ready or all older Sessions have ended. Retained old resources alone must not keep rapid polling alive. Render failed, update-required and unknown target states distinctly. Keep offline/live-provider status separate from a node's durable serving-generation pin; the pin alone never means the node is online or ready. Node detail shows the serving generation and target preparation; allocation detail shows the owned configuration generation. Do not calculate rollout completion from these rows or promise immediate placement on the target. @@ -423,7 +423,7 @@ A persistent progress panel uses Core's busy, idle and cleanup counts, deadline Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state. ### System page -Four sections, each saying where it changes. Installation: the public address, API base URL, installation ID and source commit as a fact card, with an outline action that opens the Domain and HTTPS secondary page. Default model configuration, the one section changed here: one card per harness in an auto-fill grid, its header holding the harness name and outline actions (Set, or Replace and Clear); fact rows give the harness's read-only startup state (a status dot and a Default pill, its source behind a help tip), then the default model ID, provider protocol, base URL, whether a key is configured, token limits when set and the update time, or Not set. Set and Replace open one form dialog. The model ID is required; advanced settings disclose an optional JSON object editor with formatting and inline syntax errors, plus token limits. The harness list supplies supported protocols, native protocols, JSON support and required limits from one adapter declaration. The form uses those fields without harness-specific branches. Nonempty JSON requires a native protocol; the form explains an incompatible selection beside the editor. Help tips explain the scope of native settings. Changing the model ID, provider URL or protocol clears the native JSON so settings cannot follow an unrelated model by accident. Re-entering the required write-only API key alone does not change model identity. The key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. Sandboxes: one navigation row to the Sandbox configuration secondary page; do not repeat its configuration facts on System. Startup settings: a line naming the config file and the apply command as copyable chips, with when they were last applied, over a table of each setting, its value and the services a change restarts. Sensitive settings show only Configured or Not set; Default and Fixed after install are neutral pills beside the value. +Four sections; the page help says where sandboxes and startup settings change. Installation: the public address, API base URL, installation ID and source commit as a fact card, with an outline action that opens the Domain and HTTPS secondary page. Default model configuration, the one section changed here: one card per harness in an auto-fill grid, its header holding the harness name and outline actions (Set, or Replace and Clear); fact rows give the harness's read-only startup state (a status dot and a Default pill, its source behind a help tip), then the default model ID, provider protocol, base URL, whether a key is configured, token limits when set and the update time, or Not set. Set and Replace open one form dialog. The model ID is required; advanced settings disclose an optional JSON object editor with formatting and inline syntax errors, plus token limits. The harness list supplies supported protocols, native protocols, JSON support and required limits from one adapter declaration. The form uses those fields without harness-specific branches. Nonempty JSON requires a native protocol; the form explains an incompatible selection beside the editor. Help tips explain the scope of native settings. Changing the model ID, provider URL or protocol clears the native JSON so settings cannot follow an unrelated model by accident. Re-entering the required write-only API key alone does not change model identity. The key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. Sandboxes: one navigation row to the Sandbox configuration secondary page; do not repeat its configuration facts on System. Startup settings: a line saying these are the settings Core loaded, over a table of each setting, its value and the services a change restarts. Sensitive settings show only Configured or Not set; Default and Fixed after install are neutral pills beside the value. ### One place for each task A configuration or operation has one home. Other pages link to it instead of repeating the same panel. System links to the Sandbox configuration secondary page; Nodes contains node management. Keep the configuration page flat: the resource editor is a dialog, and rollout is one status row with a details action. Put low-frequency counts and generation metadata in that dialog. Explanatory prose belongs in help tips, not rows of small print. Keep actionable errors and unresolved state visible without duplicating the whole workflow. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 2aa72c28c..f2022d3a8 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -22,7 +22,7 @@ The console runs beside the administrator's own Core, with execution, files and ## Operating Context -- Paired console (`services/web`): the administrator signs in with the deployment's Core key, the administration credential the installer writes to `secrets/core.key` under the installation directory (by default `~/.oac/core/secrets/core.key`; keeping and rotating it is described in [Core key](../../docs/getting-started/operations.md#core-key)). There are no console accounts or usernames. Sign-in shows the default file location and a copyable `cat ~/.oac/core/secrets/core.key` command for the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. +- Paired console (`services/web`): the administrator signs in with the deployment's [Core key](../../docs/getting-started/operations.md#core-key). Sign-in shows a copyable Docker Compose command to read the key on the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. - `/console/config` reports the node installer (`node_installer`, `node_installer_sha256`), offered only with a 64-hex digest. Native self-hosted installation does not depend on this endpoint. It also lists the providers it has node files for (`node_artifacts`); without the deployment's provider, Add node says so and issues no command. Signing in grants administration, sandbox administration included. - Chinese and English UI; light and dark themes; reduced motion honored. @@ -32,7 +32,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the attention table, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Agent metrics' By Agent table opens an Agent's page and, from its failed Turns, its Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity, host figures, allocations and individual node operations). Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Removal offers the host's uninstall command. System owns installation facts, the Domain and HTTPS secondary page, each harness's default model configuration, startup settings, and a link to the Sandbox configuration secondary page. That page owns setup, resource edits, rollout details and reset. Setup selects a backend, size and Runtime, then asks for a deliberate save; own-machine setup continues to Add node. -- A node whose provider is not ready names the reason (Docker unreachable, no Docker limits, missing Runtime image, no KVM, missing microsandbox components, a host too small) and its fix in the help tip beside its status, wherever that status shows. +- A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows. - A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes list and its page its status is Old address, with "Remove and add again", never Available. - **Sandbox reset** is an explicit administrator operation in System → Sandbox configuration. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save. - **Online sandbox configuration** changes the same backend's resources, Runtime or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. @@ -49,7 +49,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **Secrets stay write-only.** Credential tokens, Template environment variables and setup commands are never returned, to the administrator included. An Agent's saved model provider shows its protocol, base URL, limits and whether a key is configured, never the key. -- **Creators.** Core records the key behind every write. The console shows the creating key of each asset and a project's write history; an asset Core records as an administrator copy (`admin_copy`) shows as Admin copy and an asset without a record as Unknown. +- **Creators.** Core records the key behind every write. The console shows the creating key of each asset and a project's write history; an asset without a record shows as Unknown. - **Waiting for results.** Overview, Session log and Session details name the function whose result the calling application must submit. The console cannot submit that result; environment connection waits stay distinct from function waits. - **Session history is read-only.** A Session page reads the Session, its Items and Turns and polls while work is in flight; there is no live event stream. - **Failure diagnostics.** Failed Session and Turn rows read Core diagnostics and translate its classified reason. The console never infers a cause from raw logs. Unavailable or mismatched diagnostics offer an explicit read retry; refreshing does not replay execution. Trace Timing keeps each Item's Core receipt interval separate from public Turn times and native tool duration. Historical missing timestamps stay unknown, negative clock intervals stay missing, and bounded response truncation remains visible. @@ -59,7 +59,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. -- **Local-only address.** Overview, Nodes and System warn when Core reports `local_only`, with the configuration path and apply command Core supplies as copyable instructions. Without a configuration snapshot they state what is missing. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. +- **Local-only address.** Overview, Nodes and System warn when Core reports `local_only` and lead to System to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. - Runtime CPU and memory exist only for Core-managed hosted sandboxes. - Preserve workflow safety: confirmed deletion, no automatic retry of uncertain writes, no secrets in browser storage. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index d5a1a90ab..f8d48a28d 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -14,15 +14,15 @@ 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(); - await expect(page.getByText("cat ~/.oac/core/secrets/core.key", { exact: true })).toBeVisible(); + await expect(page.getByText('docker compose -f "$HOME/.oac/core/compose.yaml" exec -T web oac-web core-key', { exact: true })).toBeVisible(); await expect(page.getByText("For a custom installation directory, replace the path in this command.")).toBeVisible(); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await page.getByRole("button", { name: "Copy key read command" }).click(); - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("cat ~/.oac/core/secrets/core.key"); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('docker compose -f "$HOME/.oac/core/compose.yaml" exec -T web oac-web core-key'); await signIn(page, "not-the-core-key"); await expect(page.getByRole("alert")).toHaveText("This Core key is not correct. Check it and try again."); await signIn(page, FIXTURE_CORE_KEY); @@ -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..2735ebc8c 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -12,17 +12,15 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" * (loopback: only the Core machine reaches the API, and E2B is rejected) or "stale" (public, - * with a node enrolled with an earlier address), `credentials: "none"` a Core without a - * credential encryption key, which cannot store a model provider's key, and - * `installers: "none"` a console without its node installation payload, so it serves neither + * with a node enrolled with an earlier address), and `installers: "none"` a console without its node installation payload, so it serves neither * the node nor the self-hosted installer. `nodeArtifacts` lists the providers the console has * node files for, both by default; as in the console, microsandbox needs Docker's files too. */ -export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] } /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.credentials ? `&credentials=${options.credentials}` : ""}${options.installers ? `&installers=${options.installers}` : ""}${options.nodeArtifacts ? `&artifacts=${options.nodeArtifacts.join(",")}` : ""}`); + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.installers ? `&installers=${options.installers}` : ""}${options.nodeArtifacts ? `&artifacts=${options.nodeArtifacts.join(",")}` : ""}`); } const v1Requests: string[] = []; @@ -40,7 +38,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/data/admin.mjs b/apps/web/e2e/data/admin.mjs index b3a4ff179..058d35875 100644 --- a/apps/web/e2e/data/admin.mjs +++ b/apps/web/e2e/data/admin.mjs @@ -52,7 +52,7 @@ export function buildAdmin(now, base, resources) { const cacheKey = `${type}:${id}`; if (!creators.has(cacheKey)) { turn += 1; - creators.set(cacheKey, turn % 11 === 0 ? { copy: true } : turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); + creators.set(cacheKey, turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); } return creators.get(cacheKey); }; @@ -62,7 +62,6 @@ export function buildAdmin(now, base, resources) { const list = []; const push = (at, action, type, id, parent = "", creatorType = type) => { const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project, project.keys[list.length % project.keys.length]); - if (creator?.copy) return; // Administrator copies are in the audit log, not in key write history. list.push({ id: `op_${project.id.slice(5)}_${list.length}`, created_at: iso(at), api_key: creator, action, resource_type: type, resource_id: id, parent_id: parent, request_id: `req_${list.length}`, trace_id: `${list.length}`.padStart(32, "0") }); }; for (const agent of own.agents) { push(agent.created_at, "create", "agent", agent.id); if (agent.updated_at > agent.created_at) push(agent.updated_at, "update", "agent", agent.id); } @@ -81,11 +80,7 @@ export function buildAdmin(now, base, resources) { function resourceOwners(project, url) { const type = url.searchParams.get("resource_type"); const ids = (url.searchParams.get("resource_ids") ?? "").split(",").filter(Boolean).slice(0, 100); - return { data: ids.map((id) => { - const creator = creatorFor(project, type, id); - if (creator?.copy) return { resource_id: id, api_key: null, source: "admin_copy", admin_audit_id: `audit_${id.slice(-8)}` }; - return creator ? { resource_id: id, api_key: creator, source: "api_key", admin_audit_id: null } : { resource_id: id, api_key: null, source: null, admin_audit_id: null }; - }) }; + return { data: ids.map((id) => ({ resource_id: id, api_key: creatorFor(project, type, id) })) }; } function summarize(sessions) { @@ -143,7 +138,7 @@ export function buildAdmin(now, base, resources) { const auditLog = () => { const entries = []; let n = 0; - const add = (at, action, project, type, id, results = []) => entries.push({ id: `audit_${String(++n).padStart(4, "0")}`, created_at: iso(at), admin_credential_id: "a1b2c3d4", actor_label: "admin", action, project_id: project.id, resource_type: type, resource_id: id, result_ids: results, request_id: `req_admin_${n}`, trace_id: `${n}`.padStart(32, "a") }); + const add = (at, action, project, type, id) => entries.push({ id: `audit_${String(++n).padStart(4, "0")}`, created_at: iso(at), admin_credential_id: "a1b2c3d4", actor_label: "admin", action, project_id: project.id, resource_type: type, resource_id: id, request_id: `req_admin_${n}`, trace_id: `${n}`.padStart(32, "a") }); for (const project of projects) { add(project.created_at, "create_project", project, "project", project.id); for (const k of project.keys) { add(k.created_at, "issue_key", project, "api_key", k.id); if (k.revoked_at) add(k.revoked_at, "revoke_key", project, "api_key", k.id); } diff --git a/apps/web/e2e/data/agents.mjs b/apps/web/e2e/data/agents.mjs index db6b40bff..26f23e8b4 100644 --- a/apps/web/e2e/data/agents.mjs +++ b/apps/web/e2e/data/agents.mjs @@ -10,10 +10,12 @@ const mcp = (label, url, allowed = null, credential = null) => ({ ...(credential ? { x_agents_core: { credential_id: credential } } : {}), }); const str = (description) => ({ type: "string", description }); +/** Core reports both reasoning members, null when unset. */ +const reasoning = (effort = null, summary = null) => ({ effort, summary }); export const agentDefinitions = [ { - name: "Incident responder", model: "gpt-5.1-codex", harness: "codex", reasoning: { effort: "high", summary: "auto" }, + name: "Incident responder", model: "gpt-5.1-codex", harness: "codex", reasoning: reasoning("high", "auto"), instructions: "You are the on-call assistant for the web platform.\n\nWhen an alert arrives:\n1. Read the alert and the linked dashboard.\n2. Check recent deploys and error logs for the affected service.\n3. Propose the smallest safe mitigation and wait for approval before acting.\n4. Write a short incident note: impact, timeline, cause, next steps.", tools: [ fn("lookup_deploys", "List deploys of a service in a time window.", { service: str("Service name"), since: str("ISO-8601 start") }), @@ -23,7 +25,7 @@ export const agentDefinitions = [ metadata: { team: "sre", tier: "production", runbook: "RB-0142" }, }, { - name: "Support triage", model: "claude-sonnet-5", harness: "claude_sdk", reasoning: {}, + name: "Support triage", model: "claude-sonnet-5", harness: "claude_sdk", reasoning: reasoning(), instructions: "Classify each incoming support ticket by product area and severity.\nAsk one clarifying question when the report lacks steps to reproduce.\nNever promise dates. Link the matching help-center article when one exists.", tools: [ fn("search_help_center", "Search published help-center articles.", { query: str("Search text") }), @@ -32,13 +34,13 @@ export const agentDefinitions = [ metadata: { team: "support", queue: "tier-1" }, }, { - name: "Code reviewer", model: "gpt-5.1-codex", harness: "codex", reasoning: { effort: "medium" }, + name: "Code reviewer", model: "gpt-5.1-codex", harness: "codex", reasoning: reasoning("medium"), instructions: "Review pull requests for correctness first, then clarity.\nComment only on issues that matter; group nits into one comment.\nFlag missing tests for changed behaviour.", tools: [mcp("github", "https://mcp.example.com/github", ["get_pull_request", "list_files", "create_review"])], metadata: { team: "platform", repo_scope: "monorepo" }, }, { - name: "Data analyst", model: "MiniMax-M2", harness: "mcode", reasoning: {}, + name: "Data analyst", model: "MiniMax-M2", harness: "mcode", reasoning: reasoning(), instructions: "Answer questions about product metrics using the warehouse.\nShow the SQL you ran and state the time range and filters explicitly.\nIf data is missing, say so instead of estimating.", tools: [ fn("run_sql", "Run a read-only SQL query against the analytics warehouse.", { sql: str("SELECT statement") }), @@ -47,37 +49,37 @@ export const agentDefinitions = [ metadata: { team: "data", warehouse: "analytics" }, }, { - name: "Contract checker", model: "gpt-5.1-mini", harness: "codex", reasoning: {}, + name: "Contract checker", model: "gpt-5.1-mini", harness: "codex", reasoning: reasoning(), instructions: "Compare a supplier contract against the standard template.\nList every deviation with the clause number, the template wording and the contract wording.\nMark liability, termination and data-protection deviations as high priority.", tools: [fn("fetch_template", "Fetch the current standard contract template.", { kind: str("Contract type") })], metadata: { team: "legal" }, }, { - name: "Release notes writer", model: "claude-sonnet-5", harness: "claude_sdk", reasoning: {}, + name: "Release notes writer", model: "claude-sonnet-5", harness: "claude_sdk", reasoning: reasoning(), instructions: "Draft release notes from merged pull requests since the last tag.\nGroup by user-visible change; leave out refactors and CI changes.", tools: [mcp("github", "https://mcp.example.com/github", ["list_merged_pull_requests", "get_release"])], metadata: { team: "platform", channel: "stable" }, }, { - name: "Onboarding guide", model: "gpt-5.1-mini", harness: "codex", reasoning: {}, + name: "Onboarding guide", model: "gpt-5.1-mini", harness: "codex", reasoning: reasoning(), instructions: "Help new employees find internal documentation and set up their accounts.\nKeep answers short and link the source page.", tools: [mcp("docs", "https://mcp.internal.example/docs", null)], metadata: { team: "people-ops" }, }, { - name: "Churn forecaster", model: "MiniMax-M2", harness: "mcode", reasoning: { effort: "high" }, + name: "Churn forecaster", model: "MiniMax-M2", harness: "mcode", reasoning: reasoning("high"), instructions: "Estimate 90-day churn risk per account from usage and support signals.\nExplain the top three drivers for each high-risk account.", tools: [fn("run_sql", "Run a read-only SQL query against the analytics warehouse.", { sql: str("SELECT statement") })], metadata: { team: "data", model_version: "2026-09" }, }, { - name: "Log summarizer", model: "gpt-5.1-codex", harness: "codex", reasoning: {}, + name: "Log summarizer", model: "gpt-5.1-codex", harness: "codex", reasoning: reasoning(), instructions: "Summarise error logs for the last hour: top error signatures, first seen, count, affected hosts.", tools: [mcp("observability", "https://mcp.internal.example/observability", ["query_logs"])], metadata: { team: "sre" }, }, { - name: "Staging smoke tester", model: "gpt-5.1-mini", harness: "codex", reasoning: {}, + name: "Staging smoke tester", model: "gpt-5.1-mini", harness: "codex", reasoning: reasoning(), instructions: "Run the staging smoke checklist after each deploy and report failures with request IDs.", tools: [fn("http_check", "Call an endpoint and return status and latency.", { url: str("Absolute URL") })], metadata: { team: "qa", environment: "staging" }, @@ -91,13 +93,13 @@ export const agentDefinitions = [ */ export const providerAgentDefinitions = [ { - id: "agent_11c4f2a8", name: "Spec drafter", model: "MiniMax-M2", reasoning: {}, + id: "agent_11c4f2a8", name: "Spec drafter", model: "MiniMax-M2", reasoning: reasoning(), instructions: "Draft a product spec from the linked discussion: problem, users, scope, open questions.", tools: [], metadata: { team: "product" }, x_agents_core: { model_provider: { protocol: "anthropic", base_url: "https://api.minimaxi.com/anthropic", api_key_configured: true, context_window: 204800, max_output_tokens: 65536 } }, }, { - id: "agent_12d7e3b9", name: "Research assistant", model: "kimi-k2", reasoning: {}, + id: "agent_12d7e3b9", name: "Research assistant", model: "kimi-k2", reasoning: reasoning(), instructions: "Answer research questions with cited sources and a one-paragraph summary.", tools: [], metadata: { team: "research" }, x_agents_core: { harness: "codex", model_provider: { protocol: "responses", base_url: "https://api.moonshot.cn/v1", api_key_configured: true } }, diff --git a/apps/web/e2e/data/resources.mjs b/apps/web/e2e/data/resources.mjs index 79bd37bc0..a542ef781 100644 --- a/apps/web/e2e/data/resources.mjs +++ b/apps/web/e2e/data/resources.mjs @@ -3,22 +3,9 @@ let seed = 7; const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296); const hex = (n) => Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join(""); const uuid = () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`; -const iso = (seconds) => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); -export function buildResources(now, agents, sessions) { +export function buildResources(now) { seed = 7; - const keys = [ - { id: "fb533e99-524f-4e44-94bc-8f6e571646a7", name: "Production app", prefix: "pc_live_7Hq", created_at: iso(now - 86400 * 21), revoked_at: null }, - { id: "3c1d9e20-7a41-4b8e-9f02-5d6e7f8a9b10", name: "CI pipeline", prefix: "pc_live_Qm4", created_at: iso(now - 86400 * 16), revoked_at: null }, - { id: "a47e2b19-0c3d-4e5f-8a6b-7c8d9e0f1a2b", name: "Data team notebook", prefix: "pc_live_k9T", created_at: iso(now - 86400 * 9), revoked_at: null }, - { id: "0b533e99-524f-4e44-94bc-8f6e571646a7", name: "Staging", prefix: "pc_live_2Xa", created_at: iso(now - 86400 * 30), revoked_at: iso(now - 86400 * 6) }, - ]; - const refOf = (key) => ({ type: "project_api_key", id: key.id, name: key.name, prefix: key.prefix, revoked_at: key.revoked_at }); - const consoleRef = { type: "console", id: null, name: null, prefix: null, revoked_at: null }; - // Weighted owner choice: most traffic from production, some unknown (created before recording). - const owners = [refOf(keys[0]), refOf(keys[0]), refOf(keys[0]), refOf(keys[1]), refOf(keys[1]), refOf(keys[2]), refOf(keys[3]), consoleRef, null]; - const ownerOf = () => owners[Math.floor(rand() * owners.length)]; - const skills = [ ["report", "Create quarterly and incident reports from structured notes.", 3, 2], ["triage", "Sort incoming issues by severity and owner.", 2, 2], @@ -72,41 +59,8 @@ export function buildResources(now, agents, sessions) { created_at: created, updated_at: created + (index % 2 ? 86400 : 0) }; }).reverse()])); - const ownership = new Map(); - const own = (type, id, created) => { const owner = ownerOf(); ownership.set(`${type}:${id}`, { resource_type: type, resource_id: id, owner, created_at: owner ? iso(created) : null }); return owner; }; - const activity = []; - const record = (owner, action, type, id, at, parent = null) => { - if (!owner) return; - activity.push({ id: `act_${uuid()}`, object: "api_key.activity", created_at: iso(at), actor: owner, action, resource_type: type, resource_id: id, parent_resource_id: parent, trace_id: hex(32) }); - }; - for (const agent of agents) { const owner = own("agent", agent.id, agent.created_at); record(owner, "create", "agent", agent.id, agent.created_at); if (agent.updated_at > agent.created_at) record(owner, "update", "agent", agent.id, agent.updated_at); } - for (const session of sessions) { const owner = own("session", session.id, session.created_at); record(owner, "create", "session", session.id, session.created_at); if (session.last_active_at > session.created_at + 60) record(owner, "send", "session", session.id, session.last_active_at); } - for (const skill of skills) { - const owner = own("skill", skill.id, skill.created_at); - record(owner, "create", "skill", skill.id, skill.created_at); - for (const version of skillVersions.get(skill.id).slice(0, -1)) record(owner, "create", "skill_version", version.id, version.created_at, skill.id); - if (skill.default_version !== skill.latest_version) record(owner, "update", "skill", skill.id, skill.created_at + 86400); - } - for (const file of files) record(own("file", file.id, file.created_at), "create", "file", file.id, file.created_at); - for (const template of templates) { const owner = own("environment_template", template.id, template.created_at); record(owner, "create", "environment_template", template.id, template.created_at); record(owner, "update", "environment_template", template.id, template.updated_at); } - for (const { vault } of vaults) { - const owner = own("vault", vault.id, vault.created_at); - record(owner, "create", "vault", vault.id, vault.created_at); - for (const credential of credentials.get(vault.id)) { - own("vault_credential", credential.id, credential.created_at); - record(owner, "create", "vault_credential", credential.id, credential.created_at, vault.id); - if (credential.updated_at > credential.created_at) record(owner, "update", "vault_credential", credential.id, credential.updated_at, vault.id); - } - } - // Deleted resources still appear in the log. - record(refOf(keys[3]), "delete", "agent", `agent_${hex(8)}`, now - 86400 * 7); - record(refOf(keys[1]), "delete", "file", `file-${uuid()}`, now - 86400 * 2 - 600); - record(refOf(keys[1]), "delete", "skill_version", `skillver_${uuid()}`, now - 86400 * 3, skills[1].id); - activity.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); - return { - keys, skills, skillVersions, files, templates, + skills, skillVersions, files, templates, vaults: vaults.map(({ vault }) => vault), credentials, - ownership, activity, }; } diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index dd7572437..124a7966c 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -48,7 +48,7 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http const turnOutput = Math.floor(200 + rand() * 4000); const turnId = uuid(); const turn = { - id: turnId, agent_id: agent.id, session_id: id, object: "agent.session.turn", + id: turnId, agent_id: agent.id, subagent_id: null, session_id: id, object: "agent.session.turn", status: running ? "in_progress" : failed ? "failed" : rand() < 0.03 ? "cancelled" : "completed", created_at: cursor, started_at: cursor + Math.floor(rand() * 4), completed_at: running ? null : cursor + duration, error: failed ? { code: "internal_error", message: "The execution could not complete." } : null, @@ -57,15 +57,15 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http if (!running) { input += turnInput; output += turnOutput; cached += Math.floor(turnInput * 0.4); reasoning += Math.floor(turnOutput * 0.3); } sessionTurns.push(turn); const exchange = (agentConversations[agent.name] ?? [["Investigate", "Done"]])[t % (agentConversations[agent.name]?.length ?? 1)]; - sessionItems.push({ id: uuid(), turn_id: turnId, type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: exchange[0] }] }); + sessionItems.push({ id: uuid(), turn_id: turnId, type: "message", status: "completed", role: "user", phase: null, content: [{ type: "input_text", text: exchange[0] }] }); const calls = Math.floor(rand() * 6); for (let c = 0; c < calls; c += 1) { const kind = pick(["function", "function", "mcp", "command", "command", "web"]); const itemFailed = rand() < 0.06; if (kind === "function") sessionItems.push({ id: uuid(), turn_id: turnId, type: "function_call", status: itemFailed ? "failed" : "completed", call_id: uuid(), name: pick(["apply_patch", "read_file", "search_docs"]), arguments: {} }); if (kind === "mcp") sessionItems.push({ id: uuid(), turn_id: turnId, type: "mcp_call", status: itemFailed ? "failed" : "completed", server_label: pick(["github", "linear"]), name: pick(["search_issues", "get_file"]), arguments: {}, output: null, error: null }); - if (kind === "command") sessionItems.push({ id: uuid(), turn_id: turnId, type: "command_execution", status: "completed", command: "pytest -q", exit_code: itemFailed ? 1 : 0, duration_ms: 1200 }); - if (kind === "web") sessionItems.push({ id: uuid(), turn_id: turnId, type: "web_search_call", status: "completed" }); + if (kind === "command") sessionItems.push({ id: uuid(), turn_id: turnId, type: "command_execution", status: "completed", command: "pytest -q", cwd: "/workspace", output: itemFailed ? "1 failed, 41 passed" : "42 passed", exit_code: itemFailed ? 1 : 0, duration_ms: 1200 }); + if (kind === "web") sessionItems.push({ id: uuid(), turn_id: turnId, type: "web_search_call", status: "completed", action: null }); } if (!running && !failed) sessionItems.push({ id: uuid(), turn_id: turnId, type: "message", status: "completed", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: exchange[1] }] }); } @@ -85,7 +85,7 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http sessions.sort((a, b) => b.created_at - a.created_at); const nodes = [ { rollout: { state: "ready", ready_generation: 1 }, id: "node-local", name: "core-01", provider: "docker", online: true, provider_ready: true, cpu_count: 16, available_memory_bytes: 38 * 2 ** 30, available_disk_bytes: 410 * 2 ** 30, running: 5, snapshots: 2, last_seen_at: new Date((now - 8) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 5, reserved: 1, retained: 2, cleanup_pending: 0, created_at: new Date((now - 86400 * 30) * 1000).toISOString() }, - { rollout: { state: "failed", ready_generation: null, diagnostic: "docker_limits_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, diagnostic: "docker_limits_unsupported", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, + { rollout: { state: "failed", ready_generation: null, diagnostic: "host_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, diagnostic: "host_unsupported", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, { rollout: { state: "unknown", ready_generation: 1 }, id: "node-edge", name: "edge-03", provider: "docker", online: false, provider_ready: false, cpu_count: 8, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: new Date((now - 5400) * 1000).toISOString(), max_active: 4, max_retained: 8, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date((now - 86400 * 3) * 1000).toISOString() }, ]; const hosted = sessions.filter((session) => session.environment.type === "openai_hosted"); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a3de5cafa..69c03a8f5 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -40,7 +40,7 @@ const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL /** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; -/** GET /core/v1/installation: the address, the startup settings from config.json and what is bound to the address. */ +/** GET /core/v1/installation: the address, the startup settings Core loaded and what is bound to the address. */ function installation() { const local = state.installation === "local"; const setting = (key, value, fallback, restarts, extra = {}) => ({ key, value, default: fallback, changeable: true, sensitive: false, restarts, ...extra }); @@ -51,7 +51,6 @@ function installation() { object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, local_only: local, source_commit: release.source_commit, configuration: { - path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-09-24T09:30:00Z", settings: [ setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), setting("listen_address", "127.0.0.1:8091", "127.0.0.1:8091", ["core"]), @@ -93,12 +92,12 @@ function e2bDeployment() { return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; } -function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", installers = true, artifacts = "docker,microsandbox") { // Self-hosted Sessions get their remote_url from public_url, as in Core. const screenshots = process.env.OAC_WEB_SCREENSHOT_DEMO === "1"; const now = Math.floor(Date.now() / 1000); const base = (screenshots ? buildScreenshotDemo : buildDemo)(now, address === "local" ? LOCAL_URL : PUBLIC_URL); - const resources = buildResources(now, base.agents, base.sessions); + const resources = buildResources(now); const admin = buildAdmin(now, base, resources); // A fresh install: no project, Session or Runtime yet; Getting started leads. if (fresh) for (const list of [admin.projects, base.sessions, base.observations, base.allocations]) list.splice(0); @@ -113,8 +112,6 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d executorCredentials: new Map(), // How config.json's public_url is set: "public", "local" or "stale". installation: address, - // "none": Core has no credential encryption key, so it cannot store a provider's key. - credentialKey: credentials !== "none", // Startup state and deployment default model provider per harness; API keys are never kept. // The demo deployment's default harness has a default model; a fresh install has none. harnesses: { @@ -575,8 +572,6 @@ async function harnessRoute(request, response, path) { if (!support(harness).protocols.includes(input.model_provider?.protocol)) return error(response, 400, "This harness does not support this protocol.", "model_provider_protocol_unsupported"); const problem = providerProblem(harness, input.model_provider ?? {}); if (problem) return error(response, 400, problem, "invalid_request_error"); - // As Core's error mapping: sealing the key needs the credential encryption key. - if (!state.credentialKey) return error(response, 503, "Credential encryption is not configured on this service.", "credential_storage_unavailable"); entry.provider = { object: "core.model_configuration", harness, model: input.model, harness_config: input.harness_config ?? {}, model_provider: { protocol: input.model_provider.protocol, base_url: input.model_provider.base_url, api_key_configured: true, @@ -597,7 +592,7 @@ async function fixtureRoute(request, response, url) { } if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("credentials") ?? "configured", url.searchParams.get("installers") !== "none", url.searchParams.get("artifacts") ?? undefined); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("installers") !== "none", url.searchParams.get("artifacts") ?? undefined); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/deployment" && request.method === "POST") { diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index c48b1beea..be9449e51 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -24,7 +24,7 @@ test("shows the deployment's health on Overview and each monitor page", async ({ await page.getByRole("button", { name: "Sandbox metrics" }).click(); await expect(page.getByRole("table").first()).toContainText("core-01"); // A degraded node names why its provider is not ready. - await expect(page.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Docker limits unsupported", exact: true })).toBeVisible(); + await expect(page.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Host unsupported", exact: true })).toBeVisible(); }); test("opens a Session's conversation from the Session log, read-only", async ({ page, request }) => { diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 721bcf6a0..6c9bb2c4d 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -15,14 +15,12 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Add node" }).click(); const add = page.getByRole("dialog", { name: "Add node" }); - // What a Docker host needs for the default command, which installs the node with sudo. - await expect(add.getByText("Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)")).toBeVisible(); + // What a host needs for the default command, which installs the node with sudo. + await expect(add.getByText("What the sandbox backend needs on the host; the installer checks it and names anything missing")).toBeVisible(); await expect(add.getByText("SELinux is not enforcing")).toBeVisible(); await expect(add.getByText("In sudo mode, one Core per host: a host already running a sudo-mode node for another Core is refused.")).toBeVisible(); await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible(); await expect(add.getByText("Reaches https://core.example.com, as do its sandboxes")).toBeVisible(); - await expect(add.getByText("oac-node joins the docker group, which is equivalent to root on this host.")).toBeVisible(); - await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); // Node installation only offers a system service; there is no user-mode alternative. await expect(add.getByText("No sudo on this host?")).toHaveCount(0); await expect(add.getByLabel("One-time enrollment command without sudo", { exact: true })).toHaveCount(0); @@ -35,12 +33,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/); @@ -93,9 +91,9 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await expect(problem).toContainText("Not connected yet"); await expect(problem).toContainText("sudo journalctl -u oac-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service"); await expect(add.getByText("No sudo on this host?")).toHaveCount(0); - // Connected, it reports why Docker isn't ready; once ready, the node is connected. - await setNode(request, { id: "node-new", online: true, diagnostic: "docker_limits_unsupported" }); - await expect(problem).toContainText("Docker limits unsupported"); + // Connected, it reports why its provider isn't ready; once ready, the node is connected. + await setNode(request, { id: "node-new", online: true, diagnostic: "host_unsupported" }); + await expect(problem).toContainText("Host unsupported"); await expect(progress).toContainText("Waiting for Docker"); await setNode(request, { id: "node-new", provider_ready: true, diagnostic: "" }); await expect(progress).toHaveText("edge-04 · Connected"); @@ -155,7 +153,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/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 14dda4215..2b3a36a27 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -42,12 +42,11 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy await expect(page.getByRole("heading", { name: "Connect E2B" })).toBeVisible(); }); -test("lists the startup settings on System with where to change them", async ({ page, request }) => { +test("lists the startup settings on System", async ({ page, request }) => { await openConsole(page, request, "system"); const installation = page.getByRole("region", { name: "Installation" }); await expect(installation).toContainText("https://core.example.com/v1"); const startup = page.getByRole("region", { name: "Startup settings" }); - await expect(startup).toContainText("Change these in /opt/oac/config.json, then run sudo oac apply"); const settings = startup.getByRole("table", { name: "Startup settings" }); await expect(settings.getByRole("row", { name: /^log_level/ })).toContainText("debug"); await expect(settings.getByRole("row", { name: /^listen_address/ })).toContainText("Default"); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index a731ed974..50a77be5e 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -129,25 +129,6 @@ test("sets, replaces and clears a harness's default model configuration, and kee expect(browserState).not.toContain(KEY); }); -test("reports a Core without a credential key as a configuration error, without rereading", async ({ page, request }) => { - await openConsole(page, request, "system", { fresh: true, credentials: "none" }); - const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); - await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); - const set = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); - await set.getByLabel("Base URL").fill("https://model.example/v1"); - await set.getByLabel("API key").fill(KEY); - await set.getByLabel("Default model ID").fill("fixture-model"); - const reads: string[] = []; - page.on("request", (sent) => { if (sent.method() === "GET" && new URL(sent.url()).pathname === "/core/v1/harnesses") reads.push(sent.url()); }); - await set.getByRole("button", { name: "Save" }).click(); - await expect(set.getByRole("alert")).toHaveText("Core has no credential encryption key configured, so it can't store keys. Installer-based installs configure this automatically; for manual deployments, set OAC_CREDENTIAL_KEY_FILE for Core."); - await expect(set.getByRole("button", { name: "Save" })).toBeEnabled(); - expect(reads).toEqual([]); - expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-configuration"]); - await set.getByRole("button", { name: "Cancel" }).click(); - await expect(codex).toContainText("Not set"); -}); - test("reports an unconfirmed save, reads the default model configurations again once and never repeats the write", async ({ page, request }) => { await openConsole(page, request, "system", { fresh: true }); const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); 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 + + + + + + diff --git a/website/.vitepress/theme/landing-content.ts b/website/.vitepress/theme/landing-content.ts index efd797cab..418ae3222 100644 --- a/website/.vitepress/theme/landing-content.ts +++ b/website/.vitepress/theme/landing-content.ts @@ -15,6 +15,7 @@ export interface LandingCopy { copy: string hud: [string, string][] } + video: { title: string; expand: string; close: string; pause: string; play: string } ticker: string[] compose: { index: string @@ -92,6 +93,7 @@ const en: LandingCopy = { ['machine', 'linux | macos | windows'], ], }, + video: { title: 'Meet OpenAgentCore', expand: 'Watch the launch film', close: 'Close video', pause: 'Pause preview', play: 'Play preview' }, ticker: ['Codex', 'Claude Code', 'MiniMax Code', 'Responses API', 'Anthropic Messages', 'Chat Completions', 'Docker', 'microsandbox', 'E2B', 'Linux', 'macOS', 'Windows', 'PostgreSQL', 'OpenAI SDK'], compose: { index: '01', @@ -193,6 +195,7 @@ const zh: LandingCopy = { ['machine', 'linux | macos | windows'], ], }, + video: { title: '认识 OpenAgentCore', expand: '观看发布视频', close: '关闭视频', pause: '暂停预览', play: '播放预览' }, ticker: en.ticker, compose: { index: '01', diff --git a/website/README.md b/website/README.md index b9af5b4e0..2c53cfa33 100644 --- a/website/README.md +++ b/website/README.md @@ -27,6 +27,10 @@ The landing page lives in `.vitepress/theme/`. `landing-content.ts` holds its En The landing page has five numbered sections: the interactive combination example, an architecture image, execution lifecycle, console screenshots and installation. Four documentation shortcuts link to the detailed guides. `components/SessionFlow.vue` illustrates native execution, Core orchestration and multiple Session environments through a three-stage hand-drawn animation. It autoplays while visible and offers previous/next buttons, a stage slider and pause/play. Reduced-motion preferences disable autoplay. The diagram uses the locally bundled [Virgil font](https://github.com/excalidraw/virgil); its OFL license is stored beside the font in `.vitepress/theme/assets/fonts/`. Section headings share the hero’s display font with white text and green italic accents. +### Launch video + +`components/LaunchVideo.vue` embeds the CDN-hosted launch film in the hero’s right column, below an interactive ASCII brand panel, in both languages. Green corner marks frame the preview. On narrow screens, the video fills the content width between the hero actions and installation command. The compact preview loops silently and has a pause control; reduced-motion preferences leave it paused. Opening the preview pauses it and continues from the same position in a modal player with native playback and audio controls. The close button, Escape and backdrop dismiss the player and return focus to the preview. + ### Ecosystem logo wall `components/LogoWall.vue` places two full-width white logo rows between the documentation shortcuts and the closing installation section. The wall is transparent and borderless, allowing the landing page's background and grid to continue through it. Harness and model brands occupy the first row; cloud and compute brands occupy the second. They scroll in opposite directions, pause on hover or row keyboard focus, and have an explicit pause control. Reduced-motion preferences disable animation and leave both rows manually scrollable. Labels and controls use the landing page's English and Chinese copy.