diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index ee010ed18..1db726e16 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -23,12 +23,12 @@ jobs: image: postgres:16-alpine env: POSTGRES_DB: oac_ci_tests - POSTGRES_USER: agents_api - POSTGRES_PASSWORD: agents_api_test_only + POSTGRES_USER: oac + POSTGRES_PASSWORD: oac_test_only ports: - 5432/tcp options: >- - --health-cmd "pg_isready -U agents_api -d oac_ci_tests" + --health-cmd "pg_isready -U oac -d oac_ci_tests" --health-interval 5s --health-timeout 5s --health-retries 10 @@ -58,7 +58,7 @@ jobs: PY - name: Verify official-client HTTP compatibility env: - OAC_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable + OAC_TEST_DATABASE_URL: postgres://oac:oac_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable OAC_TEST_SERVER_BIN: ${{ runner.temp }}/oac-core-build/oac-core OAC_TEST_OFFICIAL_SDK_PYTHON: python run: | @@ -70,7 +70,7 @@ jobs: - name: Verify the distribution's Core image if: inputs.container env: - OAC_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable + OAC_TEST_DATABASE_URL: postgres://oac:oac_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable OAC_DEV_CORE_IMAGE: oac-core:ci OAC_TEST_OFFICIAL_SDK_PYTHON: python run: make check-core-container diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index 946466909..1471f5d52 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -33,7 +33,6 @@ jobs: node-version: '22.22.0' lockfiles: | packages/claude-sdk-adapter/pnpm-lock.yaml - packages/mcode-harness/package-lock.json - run: pnpm --dir packages/claude-sdk-adapter fetch - uses: ./.github/actions/mcode-companion diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0a307c7ad..9ee59a3ab 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -49,8 +49,8 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.sha }} - - name: Verify names, documentation and CI selection invariants - run: make check-names check-docs check-ci + - name: Verify documentation and CI selection invariants + run: make check-docs check-ci # Parts run in parallel; the serial integration package is split into deterministic shards. backend: @@ -76,12 +76,12 @@ jobs: image: postgres:16-alpine env: POSTGRES_DB: oac_core_ci_tests - POSTGRES_USER: agents_api + POSTGRES_USER: oac POSTGRES_PASSWORD: core_test_only ports: - 5432/tcp options: >- - --health-cmd "pg_isready -U agents_api -d oac_core_ci_tests" + --health-cmd "pg_isready -U oac -d oac_core_ci_tests" --health-interval 5s --health-timeout 5s --health-retries 10 @@ -119,7 +119,7 @@ jobs: sudo apt-get install -y build-essential pkg-config libssl-dev - name: Verify dedicated test database env: - OAC_TEST_DATABASE_URL: postgres://agents_api:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_core_ci_tests?sslmode=disable + OAC_TEST_DATABASE_URL: postgres://oac:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_core_ci_tests?sslmode=disable run: | echo "OAC_TEST_DATABASE_URL=$OAC_TEST_DATABASE_URL" >> "$GITHUB_ENV" make check-database @@ -231,7 +231,6 @@ jobs: with: lockfiles: | packages/claude-sdk-adapter/pnpm-lock.yaml - packages/mcode-harness/package-lock.json - name: Test and package Harness adapters run: make check-claude-sdk check-mcode-harness diff --git a/.github/workflows/ci-review.yml b/.github/workflows/ci-review.yml index 2bc1069bd..0cd33e664 100644 --- a/.github/workflows/ci-review.yml +++ b/.github/workflows/ci-review.yml @@ -16,28 +16,12 @@ 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: - # Keep GH_TOKEN available to read the PR and Actions logs. + # allowed_non_write_users turns on a subprocess secret scrub; opt out so Claude's + # commands can read GH_TOKEN and the Feishu webhook. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '0' steps: # The triggering revision is already on main, so its rules and code are trusted. @@ -46,15 +30,14 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 2 persist-credentials: false - - 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 + - name: Review and send Feishu card with Claude Code + # Notification is best effort: a failed review or delivery never fails the job. 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' @@ -71,57 +54,29 @@ jobs: allowed_bots: '*' allowed_non_write_users: '*' prompt: | - 你负责本次 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 }} 读取变更清单与完整差异,再检查相关文件。 + 你是 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 到当前目录) - ${{ matrix.instructions }} + 用 gh pr checks ${{ github.event.pull_request.number }} --repo ${{ github.repository }} 查询这个 PR 已有的检查结果,必要时读取对应 Actions 日志。不要触发新的 CI,也不要把合并本身当作检查通过的证据;管理员可能绕过门禁。检查失败、缺失或尚未完成时如实报告。 - 按 JSON schema 返回中文报告。status 为 ok(未发现问题)、issues(发现有证据的问题)或 incomplete(审查失败、范围未检查完整或检查结果尚未完成)。有问题且仍有未检查项时,用 issues 并在对应字段中说明缺项。按 schema 把各项结论分别填入字段,不要重复标题;changes 用 1–3 条 Markdown 列表,其他字段无问题时一句话,有问题时保留依据和建议,遵守各字段长度限制。 - 围绕本次 diff 和受影响的文档展开,证据充分后输出结果,避免重复核对同一结论。 - 只读审查,不修改仓库,不触发新的 CI,不发送消息。两份报告会由后续 job 合并发送。 - claude_args: >- - --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 + 任务:给飞书群发一张中文卡片(Card 2.0)总结这次 CI,尽量在 10 轮以内给出结论,工具调用尽可能的并行。 + 环境里有 gh(已登录,GH_TOKEN)、git、node 和完整的仓库代码。 - 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" + 卡片要让手机上的读者快速看懂,如果一切正常,表达的尽可能简单,如果没有问题,尽量简洁: + 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 地址和密钥。 + claude_args: >- + --allowedTools Bash Read Write Edit Glob Grep WebFetch WebSearch + --max-turns 30 diff --git a/.github/workflows/e2b-template.yml b/.github/workflows/e2b-template.yml index 692a11cbc..6537b838a 100644 --- a/.github/workflows/e2b-template.yml +++ b/.github/workflows/e2b-template.yml @@ -21,6 +21,13 @@ on: default: creative required: true + compute_profile: + description: Template CPU and memory allocation + type: choice + options: [2c2g, 2c8g, 4c8g] + default: 2c2g + required: true + resume_build: description: Existing unsubmitted templateID:build_UUID after an upload failure; requires ref type: string @@ -181,6 +188,7 @@ jobs: env: RESUME_BUILD: ${{ inputs.resume_build }} SOURCE_COMMIT: ${{ steps.source.outputs.revision }} + COMPUTE_PROFILE: ${{ inputs.compute_profile }} E2B_API_KEY: ${{ secrets.E2B_API_KEY }} E2B_API_URL: ${{ vars.E2B_API_URL }} run: | @@ -191,9 +199,16 @@ jobs: printf '%s' "$E2B_API_KEY" > "$key" unset E2B_API_KEY mkdir -p "$RUNNER_TEMP/e2b-result" + compute_args=() + case "$COMPUTE_PROFILE" in + 2c2g) ;; + 2c8g|4c8g) compute_args=(--compute-profile "$COMPUTE_PROFILE") ;; + *) echo '::error::Unknown compute profile.'; exit 1 ;; + esac "$RUNNER_TEMP/e2b-sdk/bin/python" - source/services/core/deploy/e2b/build-template.py \ --image "$(cat "$HOME/.oac/build/e2b-runtime/runtime-image.id")" \ --name "sandbase-oac-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \ + "${compute_args[@]}" \ --api-key-file "$key" --output "$RUNNER_TEMP/e2b-result/template.json" <<'PY' import hashlib, json, os, pathlib, runpy, sys from types import SimpleNamespace @@ -252,7 +267,22 @@ jobs: result = original_request(client, **kwargs) receipt = {'template': result.template_id + ':' + result.build_id, 'source_commit': os.environ['SOURCE_COMMIT'], - 'api_url': os.environ['E2B_API_URL'], 'resumed': bool(selector)} + 'api_url': os.environ['E2B_API_URL'], 'resumed': bool(selector), + 'resources_verified': False} + (output / 'build-request.json').write_text(json.dumps(receipt, indent=2) + '\n') + response = client.get_httpx_client().get(f'/templates/{result.template_id}') + response.raise_for_status() + detail = response.json() + builds = [build for build in detail.get('builds', []) + if build.get('buildID') == result.build_id] + if (detail.get('templateID') != result.template_id or len(builds) != 1 + or type(builds[0].get('cpuCount')) is not int + or type(builds[0].get('memoryMB')) is not int + or builds[0]['cpuCount'] != kwargs['cpu_count'] + or builds[0]['memoryMB'] != kwargs['memory_mb']): + raise RuntimeError('Template build CPU/memory does not match the requested compute profile') + receipt.update(cpu_count=builds[0]['cpuCount'], memory_mb=builds[0]['memoryMB'], + resources_verified=True) (output / 'build-request.json').write_text(json.dumps(receipt, indent=2) + '\n') return result sdk.request_build = request @@ -271,12 +301,18 @@ jobs: import json, os, pathlib report = pathlib.Path(os.environ['RUNNER_TEMP']) / 'e2b-result/template.json' value = json.loads(report.read_text()) + receipt = json.loads(report.with_name('build-request.json').read_text()) + if (receipt.get('resources_verified') is not True + or receipt.get('template') != value['template']): + raise RuntimeError('Build report does not match a verified allocation receipt') + value.update(cpu_count=receipt['cpu_count'], memory_mb=receipt['memory_mb']) value.update(source_commit=os.environ['SOURCE_COMMIT'], source_branch=os.environ['SOURCE_BRANCH'], api_url=os.environ['E2B_API_URL'], harnesses=['codex', 'claude_sdk', 'mcode'], qualification='image identity and native package checks; no live Session/Turn') report.write_text(json.dumps(value, indent=2) + '\n') with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out: out.write(f"Source: `{value['source_commit']}`\n\nTemplate: `{value['template']}`\n\nImage: `{value['image']}`\n\n") + out.write(f"Compute: `{value['cpu_count']} vCPU / {value['memory_mb']} MiB`\n\n") out.write('One combined Runtime template built. No Core configuration or production deployment was changed.\n') PY - uses: actions/upload-artifact@v6 diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 4047bdf3c..e5394a926 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -57,7 +57,6 @@ jobs: node-version: '22.22.0' lockfiles: | packages/claude-sdk-adapter/pnpm-lock.yaml - packages/mcode-harness/package-lock.json - name: Build the native daemon and test bundle boundaries id: build run: | @@ -137,7 +136,11 @@ jobs: node scripts/build-native-installer-ci.mjs - name: Bootstrap, authenticate, install and connect natively id: onboarding - run: node scripts/native-onboarding-smoke.mjs + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-onboarding-smoke.mjs - name: Verify native Harness protocols without model requests id: protocol run: | @@ -145,15 +148,11 @@ jobs: export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' fi node scripts/native-harness-smoke.mjs --codex-binary "$RUNNER_TEMP/native-installer/components/codex/bin/codex${{ runner.os == 'Windows' && '.exe' || '' }}" --claude-runtime "$RUNNER_TEMP/native-installer/components/claude" - - name: Archive the native distribution - run: | - cd "$RUNNER_TEMP" - tar -C native-installer -czf "oac-native-installer-${{ runner.os }}-${{ runner.arch }}.tar.gz" . - uses: actions/upload-artifact@v6 if: inputs.upload-artifacts with: name: oac-native-installer-${{ runner.os }}-${{ runner.arch }} - path: ${{ runner.temp }}/oac-native-installer-*.tar.gz + path: ${{ runner.temp }}/native-archives/*.tar.gz if-no-files-found: error retention-days: 7 compression-level: 0 diff --git a/AGENTS.md b/AGENTS.md index 70c113a2f..6bc0a950d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,37 +28,48 @@ Do not multiply entities without necessity. The long-term goal is minimal code, | Application–Core (`/v1`) | Official schema pinned by `contracts/agents-api/upstream.json` plus Go-owned `x_agents_core` fields and typed public extension routes; `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 and sandbox nodes–Workspace filesystem | `services/core/internal/workspacefs/workspacefs.go` | [Workspace filesystem protocol](docs/workspace-provider.md) | +| Core–Sandbox Provider | `services/core/internal/sandbox/sandbox_provider.go` | [Sandbox Provider protocol](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) | | Core–Runtime wire | `internal/agentdaemon/proto/` | [Core–Runtime protocol](docs/runtime-protocol.md) | -| Runtime–Harness | `apps/daemon/internal/agent/harness.go` | [Harness onboarding](contracts/agents-api/harness-onboarding.md) | +| Runtime–Harness | `apps/daemon/internal/agent/harness.go` | [Harness protocol](contracts/agents-api/harness-onboarding.md) | | Harness–Model provider | `internal/modelprovider/config.go` | [Model execution](contracts/agents-api/model-execution.md) | ### Complexity stays in the adapter -- 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. +- 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 protocol](docs/sandbox-provider.md) and [Harness protocol](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 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. +### Core assembles, Harnesses run natively + +Choosing a Harness and a model behaves like running that Harness locally with that model. The only differences are the cloud Environment, isolation, credentials and unattended operation. + +- Core assembles configuration and passes it through. When a Harness or model natively supports what an official field expresses, the adapter maps the field to the native setting and the native side runs it. If the provider cannot run it, the provider's own error returns unchanged. Core qualifies nothing on a provider's behalf. It adds nothing to model input and has no fallback, retry or substitute path. Authentication, isolation and credential protection are assembly and stay in Core. +- A Harness joins through one declaration file, `internal/harnessconfig//declaration.go`, plus its adapter code ([declare the Harness](contracts/agents-api/harness-onboarding.md#declare-the-harness)). The declaration is the only source of support facts, and every other projection is generated from it. +- An official field with no native home returns a typed error. Dropping it silently would be a fallback. +- A default defined by the pinned official API applies. Everything else follows the native default, and Core sends nothing for it. +- An adapter overrides a native default only when the Harness cannot otherwise run unattended in the cloud. The allowed reasons are: tools run without approval prompts, no ask-the-user tool, the workspace and state paths, credential isolation, bounded failure instead of waiting, no work that outlives its Turn, and no feature that needs an account credential Core does not hold. Each override has a one-line reason beside it, and the adapter guide lists it. +- Never branch on the model or provider. When a model lacks a feature, the Harness handles it as it would locally. + ### Public API - The target is the complete OpenAI Agents API (`openai/openai-python` `beta/agents`) as pinned in [`contracts/agents-api/upstream.json`](contracts/agents-api/upstream.json): paths, methods, headers, field presence, nullability, discriminators, defaults, status transitions, pagination, errors and streaming. Engine limitations are gaps to close, never grounds to narrow or redefine the contract. Operations or fields newer than the pinned baseline wait for a protocol upgrade. -- Record each native Harness difference, and any unspecified or unverified behavior, in the [coverage ledger](contracts/agents-api/index.md). Reject explicit enablement of an unsupported feature and never invent official semantics. When a material difference has no clear mapping, stop and ask before changing its semantics. Native differences never relax authentication, isolation, credential protection or data consistency. +- Record each native Harness difference, and any unspecified or unverified behavior, in the [OpenAI compatibility](contracts/agents-api/index.md). Reject explicit enablement of an unsupported feature and never invent official semantics. When a material difference has no clear mapping, stop and ask before changing its semantics. Native differences never relax authentication, isolation, credential protection or data consistency. - Applications, including the Parsar product, reach Core only through the public contract, with no privileged endpoint and no shared tables. Core never interprets their product payloads. ### One home for each setting and datum 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), [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. +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 reference](docs/configuration.md) owns the installation layout and the settings themselves. ### Pre-release: no compatibility layers -OpenAgentCore is pre-release. Replace superseded interfaces, execution paths and files outright. Keep no version fallback, compatibility shim or migration for superseded behavior unless an explicit upgrade contract requires it. The [in-place upgrade](docs/getting-started/operations.md#upgrade) from v0.0.10 or later is that contract: persisted changes ship as forward migrations (goose for PostgreSQL, `oac init` for the data volume), and a landed migration is never edited. Protocols may change; the release notes list each change with the operator actions it requires. Keep the pinned official public protocol, valid data and still-used, verified infrastructure. Do not rewrite working infrastructure only to rename it. +OpenAgentCore has had no public release. Replace superseded interfaces, execution paths and files outright. Keep no version fallback, compatibility shim or migration for superseded behavior. An installation of another release is reinstalled, never upgraded. A schema change adds a forward migration, so existing development and test databases follow it; the migrations may be folded into one baseline at any time. Keep the pinned official public protocol, valid data and still-used, verified infrastructure. Do not rewrite working infrastructure only to rename it. ## Documentation @@ -75,6 +86,8 @@ OpenAgentCore is pre-release. Replace superseded interfaces, execution paths and - For each new task, create a new Git worktree. Name its directory after that change's commit subject, in kebab-case, beside the checkout. Run `git pull --ff-only` on the base branch, and create the feature branch in that worktree before development. - After every push to a pull request, wait 60 seconds, then run `gh pr checks` and confirm CI passes. Fix any failure before reporting the work as done. +- When documents conflict, apply the latest explicit user decision and update the affected current guidance; recorded evidence does not override it. +- Review the complete diff with a fresh independent subagent that receives only the requirements, acceptance criteria, boundaries, repository path and comparison baseline, with no implementation summary, self-assessment or earlier findings. Explain these criteria to the user. Do not use `codex exec` as a substitute reviewer. [CONTRIBUTING.md](CONTRIBUTING.md#review) states the review contract. - [CONTRIBUTING.md](CONTRIBUTING.md): documentation ownership, repository boundary, workflow, independent review, required checks and naming. - [Develop OpenAgentCore](docs/development.md): setup, the repository map, focused checks and [each extension boundary](docs/development.md#choose-an-extension-boundary). -- [API index](docs/api/index.md): each route's caller and credential. +- [API namespaces and credentials](docs/api/index.md): each route's caller and credential. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d581c0b0e..6901af58d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,41 +4,23 @@ This guide owns how to work in the repository: documentation ownership, the repo ## Documentation ownership +Every subject has one owner. Most owners are the page whose title names the subject in the [documentation sidebar](docs.json), such as Configuration reference, Operations or Architecture. This table lists the owners a title does not reveal. + | Subject | Canonical source | | --- | --- | | 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](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) | -| Public wire semantics and protocol coverage | [Agents API contracts](contracts/agents-api/index.md) | -| Machine connection routes | [Machine connection API](contracts/agents-api/machine-api.md) | +| Generated files (OpenAPI documents, Harness catalog reference, architecture diagram) | The source and generator named in [Contract and schema rules](#contract-and-schema-rules) and [Branding](#branding); never edit the output by hand | | Core service setup, tests and generation | [Core service guide](services/core/README.md) | | Core implementation constraints beyond the public contracts | [Implementation constraints](services/core/IMPLEMENTATION.md) | -| Environment ownership, preparation, Skills, Plugins, packages and MCP bindings | [Environments](contracts/agents-api/environments.md) | -| Built-in Harness identifiers and display names | `internal/harnessconfig/builtin/catalog.json` and its [generated reference](contracts/agents-api/harness-catalog.md) | -| 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) | +| Built-in Harness identifiers and display names | `internal/harnessconfig//declaration.go` and its [generated reference](contracts/agents-api/harness-catalog.md) | | Codex, Claude and MiniMax Runtime adapters and images | [Codex](services/core/deploy/codex/README.md), [Claude](services/core/deploy/claude/README.md), [MiniMax](services/core/deploy/mcode/README.md) | | Claude private SDK bridge | [Claude SDK adapter](packages/claude-sdk-adapter/README.md) | | E2B template construction | [E2B template builder](services/core/deploy/e2b/README.md) | | E2B and microsandbox Provider helper implementation | [E2B helper](services/core/tools/e2b-provider/README.md), [microsandbox helper](services/core/tools/microsandbox-provider/README.md) | -| Runtime telemetry responses | [Runtime telemetry API](contracts/agents-api/runtime-observability-api.md) | -| Runtime observation, sampling, retention and export | [Runtime observability](contracts/agents-api/runtime-observability.md) | -| Distribution builds, Runtime image builds, CI and publication | [Maintainer guide](docs/maintainers.md) | +| Distribution builds, Runtime image builds, CI and publication | [Build and release OpenAgentCore](docs/maintainers.md) | | Website: landing page, bilingual documentation maintenance, documentation site build and GitHub Pages publication | [Website guide](website/README.md) | -| Self-hosted Runtime installation, recovery and local operation | [Self-hosted execution](docs/getting-started/self-hosted.md) | | Installer lifecycle, locking, generated state, managed HTTPS and downloads | [Deployment](deploy/README.md) and [Node installer](deploy/node/README.md) | -| Operator installation and alternatives | [Installation](docs/getting-started/install.md), [installation options](docs/getting-started/install-options.md) | -| Settings, defaults, files and installation layout | [Configuration](docs/configuration.md) | -| Operator commands, keys, backup and upgrades | [Operations](docs/getting-started/operations.md) | -| Web console request boundary and sign-in | [Console server](docs/web/console-server.md) | | Web page behavior and visual rules | [Web product](apps/web/PRODUCT.md), [Web design](apps/web/DESIGN.md) | | Application example behavior and local operation | [Application example](example/parsar/README.md) | @@ -48,11 +30,11 @@ This repository contains the Core API and database, Runtime daemon, Harness and ### Product and execution service separation -Core must build, deploy and run independently of product services, frontends and databases. Applications follow the [public API boundary](AGENTS.md#public-api); their feature backlogs do not define Core's public protocol or storage model. Applications that share a PostgreSQL server with Core must use separate databases, credentials and migrations. +Core builds, deploys and runs independently of product services, frontends and databases. Applications follow the [public API boundary](AGENTS.md#public-api) and, when they share a PostgreSQL server with Core, use separate databases, credentials and migrations. - 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. +- Build application orchestration on the public Session and event contract in the [Agents API guide](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. ### Optional application example @@ -70,11 +52,7 @@ Core must build, deploy and run independently of product services, frontends and Follow the [worktree rule](AGENTS.md#working-in-this-repository). Do not edit or commit implementation directly on `main`. -When documents conflict, apply the latest explicit user decision and update the affected current guidance. Recorded evidence does not override it. - -If requirements are unresolved, object ownership is unclear, or a design would need parallel compatibility paths, raise it with a concrete recommendation and tradeoffs before implementing. Continue independent work meanwhile. Do not silently preserve obsolete private designs. - -Record unrelated findings without starting them. Scope compatibility claims to the operations and placements verified. +If requirements are unresolved, object ownership is unclear, or a design would need parallel compatibility paths, raise it with a concrete recommendation and tradeoffs before implementing, and continue independent work meanwhile. Record unrelated findings without starting them. Scope compatibility claims to the operations and placements verified. ### Implementation conventions @@ -84,16 +62,11 @@ Record unrelated findings without starting them. Scope compatibility claims to t - 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). - Require absolute user-supplied working directories. - Keep test artifacts under `~/.oac/`. -- New or changed routes identify their caller and credential in the [API index](docs/api/index.md) and link their detailed contract. +- New or changed routes identify their caller and credential in [API namespaces and credentials](docs/api/index.md) and link their detailed contract. ### Review -1. After implementation and validation, have a fresh independent subagent review the complete diff. -2. Give it only the requirements, acceptance criteria, boundaries, repository path and comparison baseline. Do not give an implementation summary, self-assessment or earlier findings. Explain these criteria to the user. -3. Fix substantiated in-scope findings, validate, then use another fresh reviewer. -4. If the cycle repeats, reassess design and scope before adding changes. Report an unresolved blocker instead of broadening the task. - -Do not use `codex exec` as a substitute reviewer. +Every change gets an independent review of the complete diff against its requirements, acceptance criteria and boundaries. The reviewer receives no implementation summary or earlier findings. Fix substantiated in-scope findings, validate and review again; if the cycle repeats, reassess design and scope instead of adding changes, and report an unresolved blocker instead of broadening the task. ## Required checks @@ -101,7 +74,7 @@ Toolchain setup and focused commands are in [Develop OpenAgentCore](docs/develop ### Checks for a change -Validate the current diff and the behavior it directly affects, with the smallest checks that establish correctness. Include migration or cross-component tests only when those behaviors change. A review, documentation edit or CI configuration change does not need a full repository test run. After a follow-up edit, rerun only the checks that edit affects, and record what passed and any limits. +Validate the current diff and the behavior it directly affects, with the smallest checks that establish correctness. Include cross-component tests only when those behaviors change. A review, documentation edit or CI configuration change does not need a full repository test run. After a follow-up edit, rerun only the checks that edit affects, and record what passed and any limits. The [CI selection policy](docs/maintainers.md#continuous-integration) names affected groups. A narrower check is enough when it covers the change. `make check` is the full gate for an explicitly requested full validation and for releases. [Live acceptance](#live-acceptance) applies when native execution behavior changes. @@ -116,21 +89,26 @@ The role needs `CREATE DATABASE`: tests of database-wide state, such as the exec ### 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, 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. +- Each `internal/harnessconfig//declaration.go` is the single authored declaration of one Harness, read by Core admission and the Runtime. `make generate-harness-catalog` generates the Go registrations, 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 restrictions stay in the declaration; Runtime availability stays separate and discovery only narrows the declaration. +- `make openapi` regenerates the public Go types, the Agent request shapes, the route inventory and all three OpenAPI documents; run it after changing the pinned public schema, Go bindings, Core extensions or handler annotations, and commit the generated diffs. `make check-openapi` checks freshness and the generator, and also runs through `make check-go`. +- `scripts/generate-public-api.py` reads the checked-in, checksum-verified official source without network access. It selects Agents, Vaults, Files and Skills and follows their schema references, preserving union types, nullability, required fields and constraints. The request shapes in `services/core/internal/api/official_shapes.gen.go` project `CreateAgentParams`, `UpdateAgentParams` and `SessionAgentConfigParam`; Core checks request bodies against them before it reads an Agent configuration. Core's extension types in `contracts/agents-api/v1/` stay authored in Go and are added to the public schema during generation. +- The standard fields are generated into `v1/official.gen.go`. `go-bindings.json` lists the types Core consumes and overrides only the Go representation or field order that existing storage or custom JSON encoding requires; shared shapes use one Go type. Selected discriminated unions also generate JSON serializers that keep required nullable fields for each variant. Go input projections exclude `packages.system` to preserve its explicit rejection. Other union serializers, Core's local limits, execution admission and state transitions remain implementation code. +- `packages/agents-client/src/generated/public-api.ts` generates the TypeScript client's types, enum values and field names from the resulting public schema. The same generator projects the `/core/v1` document into `core-api.ts`, which imports the public types it references from `public-api.ts`. The internal `/core/v1` and `/api/v1` documents come from handler annotations: a response field Core always sends carries `binding:"required"`, a nullable field carries `extensions:"x-nullable"`, and a Core-owned closed set uses a named Go type with typed constants in its producing package, from which Swag derives one shared enum definition. Single-value discriminators keep `enums:` tags; fields mirroring official or Runtime-owned sets keep tags that contract tests check against their owners. The annotations only shape the documents, and the client rejects a response that breaks them. +- Contract tests verify that the public schema preserves the official definitions, extensions stay in `x_agents_core`, and all documents match registered routes. Official-client and raw HTTP tests verify behavior. Schema generation does not qualify an unimplemented feature; the known gaps in [OpenAI compatibility](contracts/agents-api/index.md#known-gaps) still apply. Upstream upgrades update the OpenAPI and SDK pins together after comparison and compatibility tests. +- `make sqlc-generate` owns only `services/core/internal/db/sqlc` (sqlc v1.29.0). - `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`. ### Compatibility evidence -Use official SDKs and upstream types or schemas. Validate raw HTTP payloads and observable workflows alongside SDK behavior. API changes preserve the pinned contracts, coverage ledger, official-client tests and Core's independent build. Verify the official-client workflow before application integration. An OpenAI endpoint is a test target only when the required capabilities and credentials are available. +Use official SDKs and upstream types or schemas. Validate raw HTTP payloads and observable workflows alongside SDK behavior. API changes preserve the pinned contracts, [OpenAI compatibility](contracts/agents-api/index.md), official-client tests and Core's independent build. Verify the official-client workflow before application integration. An OpenAI endpoint is a test target only when the required capabilities and credentials are available. Controlled fixtures and synthetic model responses qualify deterministic behavior. Live acceptance calls a real model API through Core, the daemon and the Harness adapter. Direct native probes establish feasibility only. Keep provider credentials in private test configuration, outside source, logs and task records. -For wire details the pinned SDK does not specify, probe resources you own and retain the request evidence. In the [coverage ledger](contracts/agents-api/index.md), keep observed behavior distinct from guarantees, accepted profiles distinct from complete coverage, and provider connectivity distinct from deployment qualification. +For wire details the pinned SDK does not specify, probe resources you own and retain the request evidence. In the [OpenAI compatibility](contracts/agents-api/index.md), keep observed behavior distinct from guarantees, accepted profiles distinct from complete coverage, and provider connectivity distinct from deployment qualification. ### Live acceptance -Native adapter changes require their build/check targets and live provider acceptance. Follow [Harness qualification](contracts/agents-api/harness-onboarding.md#qualify-the-adapter) for native model execution. State which checks ran, which used fixtures and which lacked prerequisites. Changes to native package pins require the same qualification; build the MiniMax companion from this revision's pinned patched sources. +Native adapter changes require their build/check targets and live provider acceptance. Follow [Qualify the adapter](contracts/agents-api/harness-onboarding.md#qualify-the-adapter) in the Harness protocol for native model execution. State which checks ran, which used fixtures and which lacked prerequisites. Changes to native package pins require the same qualification; build the MiniMax companion from this revision's pinned patched sources. ## OpenAgentCore Runtime names @@ -145,23 +123,6 @@ Native adapter changes require their build/check targets and live provider accep Provider bootstrap, Runtime images and Harness adapters must agree on these names. -[Upgrade](docs/getting-started/operations.md#upgrade) owns release changes and preservation of installed data and resources. - ## Branding -Use OpenAgentCore for public project branding. The canonical mark is `docs/assets/openagentcore-logo.svg`; Web uses its outline with cropped transparent margins, theme-aware favicon colors and dark-surface inversion. The README banner is `docs/assets/openagentcore-banner.jpeg`. The architecture diagram that README and the website show, `docs/assets/architecture.svg`, is generated by `scripts/generate-architecture-diagram.py`; change the script and run it again. The `example/parsar/` workbench keeps its own name, logo and favicon. Preserve external repository URLs and data identifiers when changing display copy. - -## OpenAgentCore name guard - -`make check-names` scans tracked text for retired branding, GitHub organization, settings and installed command names. Each exception in `scripts/name-allowlist.json` names a path glob, a regular expression and a reason. - -- An exception covers only its matched text: an allowed repository import cannot hide a retired setting elsewhere on the line. -- Keep exceptions narrow and explain the preserved contract or detection input. -- The guard fails on an exception that excuses no retired identifier. Remove an exception together with the last text it covers. - -These identities stay unchanged: - -- public `AgentCoreError`, upstream contract fields and the separate Parsar product; -- persisted credential encryption domains and native-session resume keys, so existing data can be decrypted and Sessions can resume. - -Detection inputs name the identifiers they reject. Landed migrations keep their original identifiers; application and operator examples use the current names. +Use OpenAgentCore for public project branding. The canonical mark is `docs/assets/openagentcore-logo.svg`; Web uses its outline with cropped transparent margins, theme-aware favicon colors and dark-surface inversion. The README wordmark is `docs/assets/openagentcore-wordmark-light.svg` with a dark variant, and `docs/assets/og-image.png` is the social preview. The architecture diagram that README and the website show, `docs/assets/architecture.svg` and `architecture-dark.svg`, is generated by `scripts/generate-architecture-diagram.py`; change the script and run it again. The `example/parsar/` workbench keeps its own name, logo and favicon. Preserve external repository URLs and data identifiers when changing display copy. diff --git a/Makefile b/Makefile index cc2c6b4bf..2e73eade1 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SWAG_VERSION ?= v1.16.4 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.' -check: check-ci check-harness-catalog check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-core check-claude-sdk check-web check-example check-mcode-harness +check: check-ci check-harness-catalog check-distribution check-database check-sqlc check-go check-microsandbox-provider check-core check-claude-sdk check-web check-example check-mcode-harness @printf 'OpenAgentCore checks passed.\n' .PHONY: generate-harness-catalog check-harness-catalog @@ -19,11 +19,6 @@ check-harness-catalog: python3 scripts/generate-harness-catalog.py --check python3 scripts/generate-harness-catalog.test.py -.PHONY: check-names -check-names: - python3 scripts/check-names.test.py - python3 scripts/check-names.py - check-database: @test -n "$${OAC_TEST_DATABASE_URL:-}" || { echo 'Set OAC_TEST_DATABASE_URL to a dedicated test PostgreSQL database' >&2; exit 1; } diff --git a/README.ja.md b/README.ja.md index 5d15a9c99..e12d93c55 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,78 +1,87 @@
-![OpenAgentCore — One core. Many agents.](docs/assets/openagentcore-banner.jpeg) + + + OpenAgentCore + -# OpenAgentCore +オープンソースでセルフホストできる OpenAI Agents API。公式 OpenAI SDK から、自分のマシンと自分のモデルで Codex、Claude Code、MiniMax Code を実行できます。 -複数のネイティブ実行エンジンに対応し、自分のインフラにデプロイできる OpenAI Agents API のオープンソース実装です。 +[![Release](https://img.shields.io/github/v/release/MiniMax-AI/OpenAgentCore)](https://github.com/MiniMax-AI/OpenAgentCore/releases/latest) [![License](https://img.shields.io/github/license/MiniMax-AI/OpenAgentCore)](LICENSE) -[公式サイト](https://openagentcore.dev/) · [インストール](#インストール) · [API を使う](https://openagentcore.dev/docs/getting-started/quickstart) · [ドキュメント](https://openagentcore.dev/docs/getting-started/) · [コントリビューション](CONTRIBUTING.md) +[公式サイト](https://openagentcore.dev/) · [ドキュメント](https://openagentcore.dev/docs/getting-started/) · [クイックスタート](#クイックスタート) · [コントリビューション](CONTRIBUTING.md) [English](README.md) · [简体中文](README.zh-CN.md) · **日本語**
-## コンポーネントの関係 +- **OpenAI と同じ API。** `OPENAI_BASE_URL` を設定すれば、[公式 OpenAI SDK](https://developers.openai.com/api/docs/libraries)または直接の HTTP リクエストがそのまま動作します。 +- **自分のエージェント、自分のモデル。** 各 Session は Codex、Claude Code、MiniMax Code のいずれかを手元のマシンと同じようにネイティブに実行し、設定したモデルプロバイダーを使用します。詳しくは [Harness の機能](https://openagentcore.dev/contracts/agents-api/harness-capabilities)をご覧ください。 +- **計算資源は休止し、ワークスペースは残る。** microsandbox と独立したワークスペースストレージの組み合わせでは、アイドル状態のサンドボックスが計算資源を解放し、互換性のあるノードでファイルをそのまま再開します。E2B、Docker、自分の Linux、macOS、Windows マシンでも Session を実行できます。詳しくは[Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider)をご覧ください。 +- **置き換え可能なコンポーネント。** サンドボックスプロバイダー、ワークスペースファイルシステム、Harness、モデルプロバイダーは、[定義されたプロトコル](AGENTS.md#protocols-at-every-boundary)を通じて接続されます。 -![OpenAgentCore のアーキテクチャ](docs/assets/architecture.svg) +![デプロイの概要](docs/assets/console-overview-en.webp) -アプリケーションと管理者は、次の Core API を使用します。 - -| API | パス | 利用者 | -| --- | --- | --- | -| **[Agents API](https://openagentcore.dev/docs/api/public-agent-api)** | `/v1` | アプリケーション。[OpenAI の Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) と同じプロトコルを使用 | -| **[Core API](https://openagentcore.dev/contracts/agents-api/admin-api)** | `/core/v1` | 管理者。Web 経由で利用 | - -Core は実行状態を永続化し、Runtime は Environment 内で選択した Harness を実行します。各コンポーネントは定義されたプロトコルで接続されているため、個別に置き換えられます。詳しくは[アーキテクチャガイド](https://openagentcore.dev/docs/architecture)をご覧ください。 +## クイックスタート -## OpenAgentCore とは +[前提条件](https://openagentcore.dev/docs/getting-started/install#prerequisites)に従って Docker をセットアップした Linux または macOS で、Core、Web、PostgreSQL をインストールします。 -OpenAgentCore は、自分のインフラ上で AI エージェントを実行し、[OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) を提供します。 +```sh +curl -fsSL https://openagentcore.dev/install.sh | bash +``` -- **OpenAI と同じ API。** [公式 OpenAI SDK](https://developers.openai.com/api/docs/guides/agents/sdk) または直接の HTTP リクエストで、接続先を変更するだけで利用できます。新しいクライアントを覚える必要はありません。 -- **エージェントを選択可能。** 各 [Session](https://openagentcore.dev/docs/api/public-agent-api) は、[Codex](https://github.com/openai/codex)、[Claude Code](https://code.claude.com/docs/en/overview)、[MiniMax Code](https://github.com/MiniMax-AI/minimax-code) のいずれかの[ネイティブ Harness](https://openagentcore.dev/contracts/agents-api/harness-onboarding) を実行し、[設定したモデルプロバイダー](https://openagentcore.dev/contracts/agents-api/model-execution)を使用します。 -- **計算資源を再利用し、ワークスペースを永続化。** [microsandbox](https://github.com/zerocore-ai/microsandbox) と[独立したワークスペースストレージ](https://openagentcore.dev/docs/workspace-provider)の組み合わせを推奨します。アイドル状態のサンドボックスは休止して計算資源を解放し、互換性のあるノードで再開できます。[E2B](https://e2b.dev/) はマネージド環境の選択肢です。Docker や自分の Linux、macOS、Windows マシンでの実行もサポートします。 -- **すべてのコンポーネントを置き換え可能。** [サンドボックス](https://openagentcore.dev/docs/sandbox-provider)、[ワークスペースファイルシステム](https://openagentcore.dev/docs/workspace-provider)、[Harness](https://openagentcore.dev/contracts/agents-api/harness-onboarding)、[モデルプロバイダー](https://openagentcore.dev/contracts/agents-api/model-execution)は、[定義されたプロトコル](AGENTS.md#protocols-at-every-boundary)を通じて接続されます。 +Windows では PowerShell で実行します。 -## スクリーンショット +```powershell +irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex +``` -| 概要 | エージェントの監視 | -| --- | --- | -| ![デプロイの概要](docs/assets/console-overview-en.webp) | ![エージェントの監視](docs/assets/console-agent-metrics-en.webp) | +Web で、インストーラーが表示する Core key でサインインし、パブリックアドレスを設定し、デフォルトモデルを設定し、Project API key を発行して、サンドボックスの容量(ノードまたは E2B)を追加します。各手順は[Core と Web のインストール](https://openagentcore.dev/docs/getting-started/install)をご覧ください。 -## インストール +続いて OpenAI SDK から呼び出します。`OPENAI_BASE_URL` に API のベース URL(`https://core.example/v1`)、`OPENAI_API_KEY` に Project API key を設定してください。 -[前提条件](https://openagentcore.dev/docs/getting-started/install#prerequisites)に従って Docker をセットアップした Linux または macOS で、次のコマンドを実行します。 +```python +from openai import OpenAI -```sh -curl -fsSL https://openagentcore.dev/install.sh | bash +client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY +session = client.beta.agents.sessions.create( + environment={"type": "openai_hosted"}, + input="Create /workspace/hello.txt with a short greeting, then describe it.", + extra_body={"agent": {"x_agents_core": {"harness": "codex"}}}, +) +print(session.id) ``` -Windows PowerShell の場合: +[最初の Session を実行](https://openagentcore.dev/docs/getting-started/quickstart)では、結果を待って読み戻します。 -```powershell -irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex -``` +## 仕組み -続いて、以下の手順で設定します。 + + + OpenAgentCore のアーキテクチャ + -1. インストーラーが生成した Core key で **Web(管理コンソール)にサインイン**し、**ドメインと HTTPS を設定**します。 -2. **デフォルトモデルを設定**し、**Project API key を発行**します。 -3. ノード、E2B、または自分のマシンを**実行リソースとして追加**します。 -4. OpenAI SDK で **[最初の Session を実行](https://openagentcore.dev/docs/getting-started/quickstart)** します。 +アプリケーションと管理者は、次の Core API を使用します。 -[インストールガイド](https://openagentcore.dev/docs/getting-started/install)では、各手順に加え、HTTPS の設定とローカルで手軽に試す方法を説明しています。リッスンアドレスやポートなどの設定は、[インストールオプション](https://openagentcore.dev/docs/getting-started/install-options)をご覧ください。 +| API | パス | 利用者 | +| --- | --- | --- | +| **[Agents API](https://openagentcore.dev/docs/api/public-agent-api)** | `/v1` | アプリケーション。[OpenAI の Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) と同じプロトコルを使用 | +| **[Core API](https://openagentcore.dev/contracts/agents-api/admin-api)** | `/core/v1` | 管理者。Web 経由で利用 | + +Core は実行状態を永続化し、Runtime は Environment 内で選択した Harness を実行します。各コンポーネントは定義されたプロトコルで接続されているため、個別に置き換えられます。詳しくは[アーキテクチャ](https://openagentcore.dev/docs/architecture)をご覧ください。 ## ドキュメント | 目的 | 参照先 | | --- | --- | -| インストールと運用 | [インストールガイド](https://openagentcore.dev/docs/getting-started/install)、続いて[運用ガイド](https://openagentcore.dev/docs/getting-started/operations) | -| API を使ったアプリケーション開発 | [クイックスタート](https://openagentcore.dev/docs/getting-started/quickstart)、続いて [Agents API ガイド](https://openagentcore.dev/docs/api/public-agent-api) | +| インストールと運用 | [Core と Web のインストール](https://openagentcore.dev/docs/getting-started/install)、続いて[運用](https://openagentcore.dev/docs/getting-started/operations) | +| API を使ったアプリケーション開発 | [最初の Session を実行](https://openagentcore.dev/docs/getting-started/quickstart)、続いて [Agents API ガイド](https://openagentcore.dev/docs/api/public-agent-api) | | 完成したアプリケーションの確認 | [サンプル](https://openagentcore.dev/docs/examples) | | 自分のマシンでエージェントを実行 | [セルフホスト実行](https://openagentcore.dev/docs/getting-started/self-hosted) | | Harness の機能と制限の確認 | [Harness の機能](https://openagentcore.dev/contracts/agents-api/harness-capabilities) | -| 設計の理解 | [アーキテクチャガイド](https://openagentcore.dev/docs/architecture) | -| 新しいサンドボックス、Harness などのコンポーネントの追加 | [開発ガイド](https://openagentcore.dev/docs/development) | +| 設計の理解 | [アーキテクチャ](https://openagentcore.dev/docs/architecture) | +| 新しいサンドボックス、Harness などのコンポーネントの追加 | [OpenAgentCore の開発](https://openagentcore.dev/docs/development) | + +## コントリビューションとライセンス -すべてのドキュメントは[ドキュメント一覧](https://openagentcore.dev/docs/getting-started/)から参照できます。コードを変更する前に、[コントリビューションガイド](CONTRIBUTING.md)をお読みください。 +コードを変更する前に [CONTRIBUTING.md](CONTRIBUTING.md) をお読みください。バグ報告と質問は [GitHub Issues](https://github.com/MiniMax-AI/OpenAgentCore/issues) へお寄せください。[MIT License](LICENSE) で公開しています。 diff --git a/README.md b/README.md index dacf6142f..83ef3704b 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,87 @@
-![OpenAgentCore — One core. Many agents.](docs/assets/openagentcore-banner.jpeg) + + + OpenAgentCore + -# OpenAgentCore +The OpenAI Agents API, open source and self-hosted. Run Codex, Claude Code or MiniMax Code on your own machines with your own models, through the official OpenAI SDK. -An open-source, self-hosted implementation of the OpenAI Agents API with multiple native Harnesses. +[![Release](https://img.shields.io/github/v/release/MiniMax-AI/OpenAgentCore)](https://github.com/MiniMax-AI/OpenAgentCore/releases/latest) [![License](https://img.shields.io/github/license/MiniMax-AI/OpenAgentCore)](LICENSE) -[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) +[Website](https://openagentcore.dev/) · [Documentation](https://openagentcore.dev/docs/getting-started/) · [Quickstart](#quickstart) · [Contributing](CONTRIBUTING.md) **English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
-## What it is +- **Same API as OpenAI.** Set `OPENAI_BASE_URL` and the [official OpenAI SDK](https://developers.openai.com/api/docs/libraries), or plain HTTP, works unchanged. +- **Your agent, your model.** Each Session runs Codex, Claude Code or MiniMax Code natively, as it runs on your machine, with the model provider you configure. See [Harness capabilities](https://openagentcore.dev/contracts/agents-api/harness-capabilities). +- **Compute that sleeps, workspaces that stay.** On microsandbox, which keeps every workspace on independent storage, idle sandboxes release compute and resume on a compatible node with their files intact. E2B, Docker and your own Linux, macOS or Windows machine also run Sessions. See [Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider). +- **Replaceable parts.** Sandbox Providers, workspace filesystems, Harnesses and model providers plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary). -OpenAgentCore runs AI agents on your own infrastructure behind the [OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview). +![Deployment overview](docs/assets/console-overview-en.webp) -- **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://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). -- **Reusable compute, persistent workspaces.** We recommend [microsandbox](https://github.com/zerocore-ai/microsandbox) with [independent workspace storage](https://openagentcore.dev/docs/workspace-provider): idle sandboxes suspend, release compute and resume on compatible nodes. [E2B](https://e2b.dev/) is the managed alternative; Docker and your own Linux, macOS or Windows machine remain supported execution choices. -- **Every part is replaceable.** [Sandboxes](https://openagentcore.dev/docs/sandbox-provider), [workspace filesystems](https://openagentcore.dev/docs/workspace-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). +## Quickstart -## How it fits together +Install Core, Web and PostgreSQL on Linux or macOS with [Docker](https://openagentcore.dev/docs/getting-started/install#prerequisites): -![OpenAgentCore architecture](docs/assets/architecture.svg) - -Applications and operators use these Core APIs: - -| API | Path | Used by | -| --- | --- | --- | -| **[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 | +```sh +curl -fsSL https://openagentcore.dev/install.sh | bash +``` -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). +On Windows, in PowerShell: -## Screenshots +```powershell +irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex +``` -| Overview | Agent metrics | -| --- | --- | -| ![Deployment overview](docs/assets/console-overview-en.webp) | ![Agent metrics](docs/assets/console-agent-metrics-en.webp) | +In Web, sign in with the Core key the installer prints, configure the public address, set a default model, issue a Project API key and add sandbox capacity (a node or E2B). [Install Core and Web](https://openagentcore.dev/docs/getting-started/install) covers each step. -## Install +Then call it with the OpenAI SDK, with `OPENAI_BASE_URL` set to your API base URL (`https://core.example/v1`) and `OPENAI_API_KEY` to the Project API key: -On Linux or macOS with [Docker configured](https://openagentcore.dev/docs/getting-started/install#prerequisites): +```python +from openai import OpenAI -```sh -curl -fsSL https://openagentcore.dev/install.sh | bash +client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY +session = client.beta.agents.sessions.create( + environment={"type": "openai_hosted"}, + input="Create /workspace/hello.txt with a short greeting, then describe it.", + extra_body={"agent": {"x_agents_core": {"harness": "codex"}}}, +) +print(session.id) ``` -Windows PowerShell: +[Run your first Session](https://openagentcore.dev/docs/getting-started/quickstart) waits for the result and reads it back. -```powershell -irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex -``` +## How it works -Then: + + + OpenAgentCore architecture + -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://openagentcore.dev/docs/getting-started/quickstart)** with the OpenAI SDK. +Applications and operators use these Core APIs: -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). +| API | Path | Used by | +| --- | --- | --- | +| **[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 [Architecture](https://openagentcore.dev/docs/architecture). ## Documentation | I want to | Start with | | --- | --- | -| 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) | +| Install and operate an installation | [Install Core and Web](https://openagentcore.dev/docs/getting-started/install), then [Operations](https://openagentcore.dev/docs/getting-started/operations) | +| Build an application on the API | [Run your first Session](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) | +| Run agents on my own machine | [Run self-hosted Sessions](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) | +| Add a sandbox, Harness or other component | [Develop OpenAgentCore](https://openagentcore.dev/docs/development) | + +## Contributing and license -All pages: [documentation index](https://openagentcore.dev/docs/getting-started/). Before changing code, read the [contributor rules](CONTRIBUTING.md). +Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. Report bugs and ask questions in [GitHub Issues](https://github.com/MiniMax-AI/OpenAgentCore/issues). Released under the [MIT License](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index dee2b6a84..535f2252e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,78 +1,87 @@
-![OpenAgentCore — One core. Many agents.](docs/assets/openagentcore-banner.jpeg) + + + OpenAgentCore + -# OpenAgentCore +开源、可自部署的 OpenAI Agents API。用官方 OpenAI SDK,在你自己的机器上用你自己的模型运行 Codex、Claude Code 或 MiniMax Code。 -OpenAI Agents API 的开源实现,支持多种原生 Harness,可部署在自己的基础设施上。 +[![Release](https://img.shields.io/github/v/release/MiniMax-AI/OpenAgentCore)](https://github.com/MiniMax-AI/OpenAgentCore/releases/latest) [![License](https://img.shields.io/github/license/MiniMax-AI/OpenAgentCore)](LICENSE) -[官网](https://openagentcore.dev/zh/) · [安装](#安装) · [调用 API](https://openagentcore.dev/zh/docs/getting-started/quickstart) · [文档](https://openagentcore.dev/zh/docs/getting-started/) · [参与贡献](CONTRIBUTING.md) +[官网](https://openagentcore.dev/zh/) · [文档](https://openagentcore.dev/zh/docs/getting-started/) · [快速开始](#快速开始) · [参与贡献](CONTRIBUTING.md) [English](README.md) · **简体中文** · [日本語](README.ja.md)
-## 组件关系 +- **与 OpenAI 相同的 API。** 设置 `OPENAI_BASE_URL` 后,[官方 OpenAI SDK](https://developers.openai.com/api/docs/libraries)或直接的 HTTP 调用无需改动即可使用。 +- **你的 Agent,你的模型。** 每个 Session 原生运行 Codex、Claude Code 或 MiniMax Code,使用你配置的模型提供商,与在本机运行一致。详见 [Harness 能力](https://openagentcore.dev/zh/contracts/agents-api/harness-capabilities)。 +- **计算会休眠,工作区会保留。** 在 microsandbox 上(每个工作区都保存在独立存储中),空闲沙箱释放计算资源,并在兼容节点上带着文件恢复。E2B、Docker 以及你自己的 Linux、macOS 或 Windows 机器同样可以运行 Session。详见[沙箱提供商协议](https://openagentcore.dev/zh/docs/sandbox-provider)。 +- **部件可替换。** 沙箱提供商、工作区文件系统、Harness 和模型提供商都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。 -![OpenAgentCore 架构](docs/assets/architecture.svg) +![部署概览](docs/assets/console-overview-zh.webp) -应用和管理员使用以下 Core API: - -| API | 路径 | 调用方 | -| --- | --- | --- | -| **[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://openagentcore.dev/zh/docs/architecture)。 +## 快速开始 -## 这是什么 +在 Linux 或 macOS 上,按[前置条件](https://openagentcore.dev/zh/docs/getting-started/install#prerequisites)准备好 Docker 后,安装 Core、Web 和 PostgreSQL: -OpenAgentCore 在你自己的基础设施上运行 AI Agent,对外提供 [OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview)。 +```sh +curl -fsSL https://openagentcore.dev/install.sh | bash +``` -- **与 OpenAI 相同的 API。** [官方 OpenAI SDK](https://developers.openai.com/api/docs/guides/agents/sdk) 或直接 HTTP 调用,改一下地址即可,无需学习新客户端。 -- **自选 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)。 -- **计算可复用,工作区持久化。** 推荐 [microsandbox](https://github.com/zerocore-ai/microsandbox) 配合[独立工作区存储](https://openagentcore.dev/zh/docs/workspace-provider):空闲沙箱挂起、释放计算资源,并在兼容节点上恢复。[E2B](https://e2b.dev/) 是托管计算的另一选择;也支持 Docker 和你自己的 Linux、macOS 或 Windows 机器。 -- **每个部件都可替换。** [沙箱](https://openagentcore.dev/zh/docs/sandbox-provider)、[工作区文件系统](https://openagentcore.dev/zh/docs/workspace-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)接入。 +在 Windows 上,使用 PowerShell: -## 界面预览 +```powershell +irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex +``` -| 概览 | Agent 监控 | -| --- | --- | -| ![部署概览](docs/assets/console-overview-zh.webp) | ![Agent 监控](docs/assets/console-agent-metrics-zh.webp) | +在 Web 中,用安装程序输出的 Core 密钥登录,配置公共地址,设置默认模型,签发项目 API 密钥,并添加沙箱容量(节点或 E2B)。每一步见[安装 Core 和 Web](https://openagentcore.dev/zh/docs/getting-started/install)。 -## 安装 +然后用 OpenAI SDK 调用,将 `OPENAI_BASE_URL` 设为你的 API 基础地址(`https://core.example/v1`),`OPENAI_API_KEY` 设为项目 API 密钥: -在已按[前置条件](https://openagentcore.dev/zh/docs/getting-started/install#prerequisites)准备 Docker 的 Linux 或 macOS 上: +```python +from openai import OpenAI -```sh -curl -fsSL https://openagentcore.dev/install.sh | bash +client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY +session = client.beta.agents.sessions.create( + environment={"type": "openai_hosted"}, + input="Create /workspace/hello.txt with a short greeting, then describe it.", + extra_body={"agent": {"x_agents_core": {"harness": "codex"}}}, +) +print(session.id) ``` -Windows PowerShell: +[运行第一个 Session](https://openagentcore.dev/zh/docs/getting-started/quickstart)会等待结果并读回。 -```powershell -irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex -``` +## 工作原理 -然后: + + + OpenAgentCore 架构 + -1. 用安装器生成的 Core 密钥 **登录 Web**(管理控制台),并**配置域名和 HTTPS**。 -2. **设置默认模型**,并**签发项目 API 密钥**。 -3. **添加执行资源**:节点、E2B,或你自己的机器。 -4. 用 OpenAI SDK **[运行第一个 Session](https://openagentcore.dev/zh/docs/getting-started/quickstart)**。 +应用和管理员使用以下 Core API: -[安装指南](https://openagentcore.dev/zh/docs/getting-started/install)详细介绍每一步,以及 HTTPS 配置和本地快速试用。监听地址、端口等参数见[安装配置选项](https://openagentcore.dev/zh/docs/getting-started/install-options)。 +| API | 路径 | 调用方 | +| --- | --- | --- | +| **[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://openagentcore.dev/zh/docs/architecture)。 ## 文档 | 我想要 | 从这里开始 | | --- | --- | -| 安装并运维 | [安装指南](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) | +| 安装并运维 | [安装 Core 和 Web](https://openagentcore.dev/zh/docs/getting-started/install),然后看[运维](https://openagentcore.dev/zh/docs/getting-started/operations) | +| 基于 API 开发应用 | [运行第一个 Session](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) | +| 在自己的机器上运行 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/architecture) | +| 接入新的沙箱、Harness 或其他组件 | [开发 OpenAgentCore](https://openagentcore.dev/zh/docs/development) | + +## 参与贡献与许可证 -全部文档见[文档目录](https://openagentcore.dev/zh/docs/getting-started/)。修改代码前请阅读[贡献规范](CONTRIBUTING.md)。 +修改代码前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。缺陷和问题请提交到 [GitHub Issues](https://github.com/MiniMax-AI/OpenAgentCore/issues)。基于 [MIT 许可证](LICENSE)发布。 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 4cbab1ca5..157133232 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -65,7 +65,7 @@ func TestLiveClaudeSDKCancelResume(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, 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."}} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{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 2ef7efc62..8d1bf3503 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -44,7 +44,7 @@ func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { if got := running.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { t.Fatal("unsettled outcome was exposed", got) } - if err := running.(*session).Steer(ctx, proto.PromptSteerPayload{InputID: "later", Input: proto.TextInput("later")}); !errors.Is(err, agent.ErrSteeringInactive) { + if err := running.(*session).SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "later", Input: proto.TextInput("later")}, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal("cancelled execution accepted steering", err) } if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { @@ -168,7 +168,7 @@ func cancellationRequest() proto.PromptRequestPayload { func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { if mode == "cancellation-wait" { - emit(bridgeEvent{Type: "delta", Delta: "partial"}) + emit(bridgeEvent{Type: "delta", ItemID: "item", Delta: "partial"}) if !scanner.Scan() { return } @@ -179,7 +179,7 @@ func runCancellationHelper(request startRequest, mode string, scanner *bufio.Sca // These valid observations were in flight when cancellation started. emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) - emit(bridgeEvent{Type: "delta", Delta: "tail"}) + emit(bridgeEvent{Type: "delta", ItemID: "item", Delta: "tail"}) for { if _, err := os.Stat(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "release")); err == nil { break @@ -187,7 +187,7 @@ func runCancellationHelper(request startRequest, mode string, scanner *bufio.Sca time.Sleep(time.Millisecond) } _, _ = os.Stderr.WriteString(strings.Repeat("x", 2*1024*1024)) - emit(bridgeEvent{Type: "delta", Delta: "drained"}) + emit(bridgeEvent{Type: "delta", ItemID: "item", Delta: "drained"}) emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) emit(bridgeEvent{Type: "error", Code: "cancelled"}) return @@ -199,7 +199,7 @@ func runCancellationHelper(request startRequest, mode string, scanner *bufio.Sca } emit(bridgeEvent{Type: "input_ready", SessionID: id}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: json.RawMessage(usageFixture)}) - emit(bridgeEvent{Type: "delta", Delta: "partial"}) + emit(bridgeEvent{Type: "delta", ItemID: "item", Delta: "partial"}) } emit(bridgeEvent{Type: "error", Code: "execution_failed"}) } diff --git a/apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go new file mode 100644 index 000000000..776fe8f17 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go @@ -0,0 +1,140 @@ +//go:build linux + +package claudesdk + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// TestLiveClaudeSDKNativeCombinations proves structured output beside multi_agent, +// a function tool and deferred tool search on the real SDK and provider. +func TestLiveClaudeSDKNativeCombinations(t *testing.T) { + entrypoint, keyFile, proofRoot := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") + endpoint, model := os.Getenv("OAC_TEST_CLAUDE_SDK_BASE_URL"), os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL") + if entrypoint == "" || keyFile == "" || endpoint == "" || model == "" || !filepath.IsAbs(proofRoot) { + t.Skip("real SDK/provider acceptance requires an entrypoint, a private key file, an endpoint, a model and an absolute proof directory") + } + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(proofRoot, 0o700); err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(proofRoot, "claude-combination-") + if err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=" + endpoint, "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), + "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=" + model, "ANTHROPIC_DEFAULT_OPUS_MODEL=" + model, "ANTHROPIC_DEFAULT_HAIKU_MODEL=" + model, + }} + schema := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}`) + lookup := func(deferred bool) []proto.FunctionTool { + return []proto.FunctionTool{{Name: "lookup", Description: "Return the synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`), DeferLoading: deferred}} + } + // A stateless JSON-RPC MCP server with one tool. + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var rpc struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.Unmarshal(body, &rpc) + var result string + switch rpc.Method { + case "initialize": + result = `{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"proof","version":"0"}}` + case "tools/list": + result = `{"tools":[{"name":"echo","description":"Return the synthetic MCP value.","inputSchema":{"type":"object","properties":{}}}]}` + case "tools/call": + result = `{"content":[{"type":"text","text":"synthetic-mcp-value"}]}` + default: + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(rpc.ID) + `,"result":` + result + `}`)) + })) + defer mcpServer.Close() + mcp := func(req *proto.PromptRequestPayload) { + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "proof", ServerURL: mcpServer.URL}} + } + cases := map[string]func(*proto.PromptRequestPayload){ + "multi_agent": func(req *proto.PromptRequestPayload) { + req.DisableSubagents, req.ObserveSubagentIdentities = false, true + }, + "mcp": mcp, + "mcp_tool_search": func(req *proto.PromptRequestPayload) { + mcp(req) + req.FunctionTools, req.ToolSearch = lookup(true), true + }, + "function": func(req *proto.PromptRequestPayload) { req.FunctionTools = lookup(false) }, + "tool_search": func(req *proto.PromptRequestPayload) { + req.FunctionTools, req.ToolSearch = lookup(true), true + }, + } + for name, configure := range cases { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + req := proto.PromptRequestPayload{RunID: uuid.NewString(), DisableExecutionEnvironment: true, DisableSubagents: true, + Input: proto.TextInput("Call the lookup tool if one is available and put its value in answer. Otherwise answer with the word ready."), + ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}, + AgentOptions: map[string]any{"model": model, "system_prompt": "Reply only through the structured output."}} + configure(&req) + out := make(chan proto.Envelope, 64) + running, err := startSingleTurn(ctx, config, req, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + s := running.(*session) + var done, called bool + var content, failure string + for event := range out { + switch event.Type { + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + called = true + text := "synthetic-verification-value" + if err := s.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: uuid.NewString(), Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}); err != nil { + t.Fatal(err) + } + case proto.TypeError: + var payload proto.ErrorPayload + _ = json.Unmarshal(event.Payload, &payload) + failure = payload.Error + case proto.TypeDone: + var payload proto.DonePayload + _ = json.Unmarshal(event.Payload, &payload) + done, content = true, payload.Content + } + } + var answer struct { + Answer string `json:"answer"` + } + if !done || failure != "" || json.Unmarshal([]byte(content), &answer) != nil || answer.Answer == "" { + t.Fatalf("structured output failed: done=%v failure=%q content=%q", done, failure, content) + } + t.Logf("%s: function_called=%v answer=%q", name, called, answer.Answer) + }) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/contracts.go b/apps/daemon/internal/agent/claudesdk/contracts.go index 3d6be7a98..939607cf9 100644 --- a/apps/daemon/internal/agent/claudesdk/contracts.go +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -9,7 +9,6 @@ var ( _ agent.Turn = (*session)(nil) _ agent.Session = (*session)(nil) _ agent.DurableSteerer = (*session)(nil) - _ agent.Steerer = (*session)(nil) _ agent.FunctionResultSubmitter = (*session)(nil) _ agent.WorkspaceReader = (*session)(nil) _ agent.WorkspaceDirectoryLister = (*session)(nil) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index c1d903d98..5e0447bb7 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -1,7 +1,5 @@ package claudesdk -import configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" - import ( "context" "fmt" @@ -13,43 +11,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) const claudeSDKEntrypointEnv = "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" const claudeSDKNodeEnv = "OAC_RUNTIME_CLAUDE_SDK_NODE" -// Declaration owns Claude SDK discovery, configuration and execution factories. -var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - RetainedNativeHistory: proto.CapabilityUnsupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilitySupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilitySupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, -}}, Configuration: configuration.Configuration(), Discover: discover} +// Declaration binds the shared Claude Code declaration to discovery and execution factories. +var Declaration = agent.Declaration{Declaration: declaration.Declaration, Discover: discover} func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { return discoverWithCheck(ctx, options, info, CheckRuntime) @@ -64,7 +33,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d fail := func(err error) *agent.Runtime { fmt.Fprintf(options.Stderr, "oac-daemon: configured Claude SDK runtime unavailable: %v\n", err) - return out + return agent.Unavailable(descriptor) } if !filepath.IsAbs(entrypoint) { return fail(fmt.Errorf("%s must be absolute", claudeSDKEntrypointEnv)) @@ -100,7 +69,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if err != nil { return fail(err) } - config, err = ConfigureLocal(config, options.StateRoot, options.RuntimeRoot, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) + config, err = ConfigureLocal(config, options.StateRoot, options.RuntimeRoot, os.Getenv("OAC_RUNTIME_WORKSPACE")) if err != nil { return fail(err) } @@ -109,35 +78,28 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if err != nil { return fail(err) } - if config.Workspace != nil { - if !info.SupportsLocalRuntime() { - return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) - } - caps := &out.Info.Capabilities - caps.EnvironmentNone, caps.FunctionTools = proto.CapabilityUnsupported, proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) - caps.LocalEnvironment, caps.WorkspaceReadPreparation = proto.CapabilitySupported, proto.CapabilitySupported - caps.NativeSessionRecovery = proto.CapabilitySupported - caps.RetainedNativeHistory = proto.CapabilityFromBool(info.SDK == "0.3.269" && info.Native == "2.1.269 (Claude Code)") + local := config.Workspace != nil + if local && !info.SupportsLocalRuntime() { + return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) } out.Info.Available, out.Info.Version = true, info.SDK - out.Info.Capabilities.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) - out.Info.Capabilities.FunctionResultImages = proto.CapabilityFromBool(info.SupportsFunctionResultImages()) - out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsToolSearch()) - if config.Workspace != nil { - out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()) - } - out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsStructuredOutput()) - if config.Workspace != nil { - out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsWorkspaceStructuredOutput()) - } - out.Info.Capabilities.SubagentObservations = proto.CapabilityFromBool(info.SupportsSubagents()) - out.Info.Capabilities.MCPHTTPTools = proto.CapabilityFromBool(info.SupportsHTTPMCP()) - out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityFromBool(info.SupportsHTTPMCPBearer()) - out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityFromBool(info.SupportsHTTPMCPRequired()) - if config.Workspace != nil && !info.SupportsWorkspaceMCP() { - out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityUnsupported, proto.CapabilityUnsupported - out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityUnsupported - } + caps := &out.Info.Capabilities + // The local workspace layout replaces environment:none. + agent.Narrow(&caps.EnvironmentNone, !local) + for _, capability := range []*proto.CapabilitySupport{&caps.LocalEnvironment, &caps.WorkspaceReadPreparation, &caps.WorkspaceOutputExport, &caps.NativeSessionRecovery} { + agent.Narrow(capability, local) + } + agent.Narrow(&caps.RetainedNativeHistory, local && info.SDK == "0.3.269" && info.Native == "2.1.269 (Claude Code)") + agent.Narrow(&caps.FunctionTools, !local || info.SupportsWorkspaceFunctions()) + agent.Narrow(&caps.MessageImages, info.SupportsMessageImages()) + agent.Narrow(&caps.FunctionResultImages, info.SupportsFunctionResultImages()) + agent.Narrow(&caps.ToolSearch, info.SupportsToolSearch() && (!local || info.SupportsWorkspaceToolSearch())) + agent.Narrow(&caps.StructuredOutput, info.SupportsStructuredOutput() && (!local || info.SupportsWorkspaceStructuredOutput())) + agent.Narrow(&caps.SubagentObservations, info.SupportsSubagents()) + mcp := !local || info.SupportsWorkspaceMCP() + agent.Narrow(&caps.MCPHTTPTools, mcp && info.SupportsHTTPMCP()) + agent.Narrow(&caps.MCPHTTPBearerAuth, mcp && info.SupportsHTTPMCPBearer()) + agent.Narrow(&caps.MCPHTTPRequired, mcp && info.SupportsHTTPMCPRequired()) out.Executor = NewExecutorFactory(config) fmt.Fprintf(options.Stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index de5271b7b..f5376c81b 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -6,13 +6,12 @@ import ( "io" "os" "path/filepath" - "reflect" "slices" "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/contracttest" ) func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { @@ -26,7 +25,7 @@ func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { } else { root = "relative-home" } - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { t.Fatal("invalid paths reached runtime probe") return RuntimeInfo{}, nil }) @@ -47,7 +46,7 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { } t.Setenv(claudeSDKNodeEnv, node) for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { info := RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} return info, nil }) @@ -64,23 +63,24 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { } } -// The declaration must retain the complete baseline capability descriptor. -func TestDeclaredCapabilityBaseline(t *testing.T) { - expected := map[string]bool{"Streaming": true, "Usage": true, "Resume": true, "Steering": true, "MessageItems": true, "ToolObservations": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "ExecutionControls": true, "SubagentControl": true, "DurableInputReceipts": true, "DurableTurns": true, "FunctionTools": true} - value := reflect.ValueOf(Declaration.Info.Capabilities) - for i := 0; i < value.NumField(); i++ { - name := value.Type().Field(i).Name - want := proto.CapabilityUnsupported - if expected[name] { - want = proto.CapabilitySupported - } - if got := value.Field(i).Interface(); got != want { - t.Errorf("%s = %v, want %v", name, got, want) - } - } - if err := Declaration.Info.ValidateDeclaration(); err != nil { +// Discovery narrows the declaration to the installed bundle and never widens it. +func TestDiscoveryOnlyNarrowsDeclaration(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) + node, err := os.Executable() + if err != nil { t.Fatal(err) } + t.Setenv(claudeSDKNodeEnv, node) + features := []string{"message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"} + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { + return RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features}, nil + }) + if out == nil || !out.Info.Available { + t.Fatal("runtime unavailable") + } + contracttest.RequireNarrowed(t, Declaration.Capabilities, out.Info.Capabilities) } func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { @@ -100,7 +100,7 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, entrypoint) } calls := 0 - runtime := discoverWithCheck(t.Context(), options, Declaration.Info, func(_ context.Context, c Config) (RuntimeInfo, error) { + runtime := discoverWithCheck(t.Context(), options, Declaration.Info(), func(_ context.Context, c Config) (RuntimeInfo, error) { calls++ if c.Node != node || c.Entrypoint != entrypoint || c.StateDir != filepath.Join(options.StateRoot, "daemon", "test", "runtime", "claude-sdk") || c.Env != nil { t.Fatalf("configuration: %+v", c) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 0381cf750..e35e48068 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -22,27 +22,57 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { if err != nil { t.Fatal(err) } - request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} + request.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "medium"}, TextVerbosity: "medium"} before, _ := json.Marshal(request) controlled, _, err := prepare(config, request) if err != nil { t.Fatal(err) } after, _ := json.Marshal(request) + if controlled.WebSearch != "disabled" || ordinary.WebSearch != "" { + t.Fatal("disabled web search did not reach the bridge") + } + controlled.WebSearch = "" if !reflect.DeepEqual(ordinary, controlled) || string(before) != string(after) { t.Fatal("default controls changed native input, instructions, continuation or caller options") } + // Without a search control Claude Code keeps its native web tools. + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} + absent, _, err := prepare(config, request) + if err != nil || !reflect.DeepEqual(ordinary, absent) { + t.Fatal("absent search control changed the native configuration", err) + } +} + +func TestLiveWebSearchReachesTheBridgeOnEveryPlacement(t *testing.T) { + live := &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}, TextVerbosity: "medium"} + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + none, _, err := prepare(Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")}, proto.PromptRequestPayload{ + RunID: "run", Input: proto.TextInput("Search."), ExecutionControls: live, AgentOptions: map[string]any{"model": "native-model"}}) + if err != nil || none.WebSearch != "live" { + t.Fatal("live search on environment none did not reach the bridge", err) + } + request := workspaceRequest() + request.ExecutionControls = live + workspace, _, err := prepare(workspaceFixture(t), request) + if err != nil || workspace.WebSearch != "live" { + t.Fatal("live search on a workspace did not reach the bridge", err) + } } func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { + enabled := true cases := map[string]proto.ExecutionControls{ - "empty": {}, "missing-search": {TextVerbosity: "medium"}, "missing-verbosity": {WebSearch: "disabled"}, - "cached-search": {WebSearch: "cached", TextVerbosity: "medium"}, - "live-search": {WebSearch: "live", TextVerbosity: "medium"}, - "unknown-search": {WebSearch: "invalid", TextVerbosity: "medium"}, - "low-verbosity": {WebSearch: "disabled", TextVerbosity: "low"}, - "high-verbosity": {WebSearch: "disabled", TextVerbosity: "high"}, - "unknown-verbosity": {WebSearch: "disabled", TextVerbosity: "invalid"}, + "cached-search": {WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "medium"}}, + "enabled-ptc": {ProgrammaticToolCalling: &enabled}, + "low-verbosity": {TextVerbosity: "low"}, + "high-verbosity": {TextVerbosity: "high"}, + "unknown-verbosity": {TextVerbosity: "invalid"}, + "no-effort": {ReasoningEffort: "none"}, + "minimal-effort": {ReasoningEffort: "minimal"}, + "summary": {ReasoningSummary: "concise"}, + "service-tier": {ServiceTier: "priority"}, } for name, controls := range cases { t.Run(name, func(t *testing.T) { @@ -51,7 +81,7 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { 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 := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) - if err == nil || !strings.Contains(err.Error(), "execution controls require") { + if err == nil || !strings.Contains(err.Error(), "not supported by this Harness") { t.Fatal("unsupported controls did not fail at admission", err) } if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { @@ -61,6 +91,22 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { } } +func TestReasoningEffortReachesNativeOptions(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + for _, effort := range []string{"low", "medium", "high", "xhigh", "max"} { + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: effort}, AgentOptions: map[string]any{"model": "native-model", "harness_config": map[string]any{"thinking": map[string]any{"type": "adaptive"}}}} + start, _, err := prepare(config, request) + if err != nil { + t.Fatal(effort, err) + } + if start.NativeModelOptions == nil || start.NativeModelOptions.Effort != effort || start.NativeModelOptions.Thinking["type"] != "adaptive" { + t.Fatalf("effort %s: %+v", effort, start.NativeModelOptions) + } + } +} + func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) @@ -81,7 +127,7 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ObserveMessages: true, DisableSubagents: true, AgentOptions: map[string]any{"model": "model", "system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, AgentOptions: map[string]any{"model": "model", "system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} start, _, err := prepare(config, request) if err != nil { t.Fatal(err) @@ -94,13 +140,14 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { t.Fatal("lossy schema accepted") } request.ExecutionControls.OutputFormat.Schema = schema - request.DisableSubagents = false - if _, _, err := prepare(config, request); err == nil { - t.Fatal("unqualified subagent combination accepted") + request.DisableExecutionEnvironment = true + request.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} + if start, _, err := prepare(config, request); err != nil || start.OutputFormat == nil { + t.Fatal("native structured output was narrowed by MCP", err) } } -func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing.T) { +func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} @@ -112,13 +159,27 @@ func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions, request.FunctionTools) { t.Fatal("function discovery changed native definitions", err) } - request.DisableSubagents = false - if _, _, err := prepare(config, request); err == nil { - t.Fatal("unqualified combination admitted") + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(`{"type":"object"}`)}} + if start, _, err := prepare(config, request); err != nil || !start.ToolSearch || start.OutputFormat == nil { + t.Fatal("native tool search was narrowed by structured output", err) } - request.DisableSubagents = true + request.ExecutionControls = nil request.ToolSearch = false if _, _, err := prepare(config, request); err == nil { t.Fatal("deferred definitions became eager") } } + +func TestReasoningSummaryReachesTheBridgeOnlyWhenRequested(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input."), AgentOptions: map[string]any{"model": "native-model"}} + for _, summary := range []string{"", "auto"} { + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", ReasoningSummary: summary} + start, _, err := prepare(config, request) + if err != nil || start.ReasoningSummary != (summary != "") { + t.Fatalf("summary %q: reasoning flag %v, err %v", summary, start.ReasoningSummary, err) + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go index bba0b7099..1079eb29c 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -38,7 +38,7 @@ func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { if mode == "pending_input" { inputReceipt = make(chan error, 1) go func() { - inputReceipt <- turn.(*session).SteerWithReceipt(t.Context(), proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("extra")}, nil) + inputReceipt <- turn.(*session).SteerWithReceipt(t.Context(), proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("extra")}, func() {}) }() if event := <-out; event.Type != proto.TypeDelta { t.Fatal("input write barrier missing") diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go index 669f2dce1..16059494d 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{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, ExecutionControls: &proto.ExecutionControls{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_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index 7dab4ca5c..05fdb543e 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -95,12 +95,12 @@ func runPersistentExecutorHelper() { } active = command.TurnID if os.Getenv("SDK_EXECUTOR_MODE") == "late" && old != "" { - encode(bridgeEvent{Type: "delta", TurnID: old, Delta: "late"}) + encode(bridgeEvent{Type: "delta", TurnID: old, ItemID: "item", Delta: "late"}) continue } encode(bridgeEvent{Type: "turn_started", TurnID: active}) encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) - encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) + encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "item", Delta: "partial"}) if strings.HasPrefix(os.Getenv("SDK_EXECUTOR_MODE"), "pending_function") { encode(bridgeEvent{Type: "function_call", TurnID: active, Call: &proto.FunctionCallPayload{CallID: "call", Name: "lookup", Arguments: json.RawMessage("{}")}}) } @@ -114,7 +114,7 @@ func runPersistentExecutorHelper() { continue } // A full bridge write has happened, but no native input receipt exists. - encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "steer-written"}) + encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "item", Delta: "steer-written"}) case "turn_cancel": if active == command.TurnID { settle(true) diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index 97c6638cf..418de6b14 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -57,6 +57,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { terminal := false mcp := mcpState{calls: map[string]proto.ToolObservation{}} commands := commandState{calls: map[string]proto.ToolObservation{}} + searches := searchState{calls: map[string]proto.ToolObservation{}} settlementReceived := false settlementConfirmed := false cancelled := false @@ -92,13 +93,18 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { failure = err s.invalidate() } + case "search_observation": + if err := searches.receive(event, start, s.inputSessionID(), emit); err != nil { + failure = err + s.invalidate() + } case "mcp_observation": if err := mcp.receive(event, start, emit); err != nil { failure = err s.invalidate() } case "delta": - if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { + if event.ItemID == "" { failure = fmt.Errorf("claudesdk: invalid message delta identity") s.invalidate() break @@ -108,7 +114,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) case "output_message": message := event.Message - if !start.ObserveMessages || message == nil || message.ID == "" || + if message == nil || message.ID == "" || (message.Status != "in_progress" && message.Status != "completed") || (message.Status == "completed") != (message.Text != nil) { failure = fmt.Errorf("claudesdk: invalid message observation") @@ -116,7 +122,15 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { break } emit(proto.TypeOutputMessage, message) - case "function_call", "function_applied": + case "reasoning": + reasoning := event.Reasoning + if !start.ReasoningSummary || reasoning == nil || !reasoning.Valid() { + failure = fmt.Errorf("claudesdk: invalid reasoning observation") + s.invalidate() + break + } + emit(proto.TypeReasoning, reasoning) + case "function_call", "function_withdrawn", "function_applied": if err := s.receiveFunction(event, start, emit); err != nil { failure = err s.invalidate() @@ -159,7 +173,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) case "result": - if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() || !searches.complete() { failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") s.settlementErr = failure s.invalidate() @@ -194,11 +208,12 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } // Close result admission before deciding which unanswered calls cancellation settled. s.stopFunctions() - if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() || !searches.complete() { settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" } mcp.close(emit) commands.close(emit) + searches.close(emit) s.stopSteering() metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} if id := s.inputSessionID(); id != "" { @@ -223,7 +238,6 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } close(s.settled) if failure != nil { - // A valid Turn boundary replaces the former process-exit boundary. // A native diagnostic does not itself confirm cancellation or reuse. if failure != classifiedFailure || !settlementReceived { engineCode = "" diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go index 752d1347f..6aef261f1 100644 --- a/apps/daemon/internal/agent/claudesdk/functions.go +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -41,8 +41,11 @@ func validateFunctions(tools []proto.FunctionTool) error { func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit func(string, any)) error { var call *proto.FunctionCallPayload + var withdrawn *proto.FunctionCallWithdrawnPayload var observation *proto.ToolObservation var receipt chan error + // A Subagent's call is Core's to resolve through the child; the root's tool observation does not describe it. + child := false err := func() error { s.functions.mu.Lock() defer s.functions.mu.Unlock() @@ -61,16 +64,31 @@ func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit fu } } } - if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil { + child = call != nil && call.NativeID != "" + if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil || + (call.NativeID == "") != (call.TurnID == "") || child && start.Subagents == nil { return fmt.Errorf("claudesdk: invalid function call") } s.functions.calls[call.CallID] = &pendingFunction{call: *call, receipt: make(chan error, 1)} observation = &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: call.Name, Arguments: call.Arguments} + case "function_withdrawn": + withdrawn = event.Withdrawn + var pending *pendingFunction + if withdrawn != nil { + pending = s.functions.calls[withdrawn.CallID] + } + if pending == nil || pending.applied || pending.call.NativeID == "" || pending.call.NativeID != withdrawn.NativeID || pending.call.TurnID != withdrawn.TurnID { + return fmt.Errorf("claudesdk: invalid function withdrawal") + } + // Native stopped waiting for the child's call, so a result in flight is never applied. + delete(s.functions.calls, withdrawn.CallID) + pending.receipt <- agent.ErrUnknownFunctionCall case "function_applied": pending := s.functions.calls[event.CallID] if pending == nil || pending.result == nil || pending.applied || pending.result.DeliveryID != event.DeliveryID { return fmt.Errorf("claudesdk: invalid native function receipt") } + child = pending.call.NativeID != "" pending.applied = true receipt = pending.receipt status := "completed" @@ -84,11 +102,17 @@ func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit fu if err != nil { return err } + if withdrawn != nil { + emit(proto.TypeFunctionCallWithdrawn, withdrawn) + return nil + } 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 !child { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + } if call != nil { emit(proto.TypeFunctionCall, call) } else { @@ -104,14 +128,6 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio if err := result.ValidateContent(); err != nil { return err } - for _, part := range result.Content { - if part.Type == "input_image" && !result.Success { - return fmt.Errorf("claudesdk: native error results cannot retain images") - } - } - if err := (proto.MessageInput{{Content: result.Content}}).ValidateInlineImages(); err != nil { - return err - } data, err := json.Marshal(struct { Type string `json:"type"` TurnID string `json:"turn_id"` diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index d9c358b83..5f99ec7bb 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -51,11 +51,6 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { t.Fatal("missing content consumed call") } - image := "https://example.invalid/image" - invalid.Content = []proto.InputContent{{Type: "input_image", ImageURL: &image}} - if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { - t.Fatal("image should fail before delivery") - } first, second := "first-"+call.CallID, "second-"+call.CallID value := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: call.CallID == "b", Content: []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} go func() { submissions <- submitter.SubmitFunctionResult(ctx, value) }() @@ -126,7 +121,7 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner results[value.CallID] = value } if mode == "functions-cancel" { - emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) + emit(bridgeEvent{Type: "delta", ItemID: "item", Delta: "waiting for confirmation"}) if scanner.Scan() { emit(bridgeEvent{Type: "error", Code: "cancelled"}) } @@ -144,3 +139,26 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner } emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "done"}) } + +func TestSubagentFunctionCallWithdrawal(t *testing.T) { + s := &session{functions: functionState{calls: map[string]*pendingFunction{}}} + start := startRequest{Functions: []proto.FunctionTool{{Name: "lookup"}}, Subagents: &subagentOptions{}} + var emitted []string + emit := func(kind string, _ any) { emitted = append(emitted, kind) } + call := &proto.FunctionCallPayload{CallID: "a", Name: "lookup", Arguments: json.RawMessage(`{}`), NativeID: "child", TurnID: "child-turn"} + if err := s.receiveFunction(bridgeEvent{Type: "function_call", Call: call}, start, emit); err != nil { + t.Fatal(err) + } + receipt := s.functions.calls["a"].receipt + withdrawn := &proto.FunctionCallWithdrawnPayload{CallID: "a", NativeID: "child", TurnID: "child-turn"} + if err := s.receiveFunction(bridgeEvent{Type: "function_withdrawn", Withdrawn: withdrawn}, start, emit); err != nil { + t.Fatal(err) + } + // A child's call has no root tool observation, and a result in flight learns that it was never applied. + if strings.Join(emitted, ",") != proto.TypeFunctionCall+","+proto.TypeFunctionCallWithdrawn || <-receipt != agent.ErrUnknownFunctionCall { + t.Fatal(emitted) + } + if s.receiveFunction(bridgeEvent{Type: "function_withdrawn", Withdrawn: withdrawn}, start, emit) == nil { + t.Fatal("repeated withdrawal accepted") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go index cf5dbd5a0..ae4dc36e1 100644 --- a/apps/daemon/internal/agent/claudesdk/harness_config_test.go +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -12,8 +12,8 @@ func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - native := map[string]any{"effort": "high", "thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} - req := proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "harness_config": native}} + native := map[string]any{"thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} + req := proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "harness_config": native}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: "high"}} start, _, err := prepareConfiguration(config, req) 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 1f534d1a4..c3da9e8ef 100644 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -137,7 +137,7 @@ 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, 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, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{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.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}`)}} diff --git a/apps/daemon/internal/agent/claudesdk/local.go b/apps/daemon/internal/agent/claudesdk/local.go index d8bf94c47..d6a465cda 100644 --- a/apps/daemon/internal/agent/claudesdk/local.go +++ b/apps/daemon/internal/agent/claudesdk/local.go @@ -5,25 +5,20 @@ import ( "os" "path/filepath" "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // ConfigureLocal selects the qualified, dedicated Runtime layout. The shared // localworkspace binding still authorizes every request against its Session. -func ConfigureLocal(config Config, stateRoot, root, workspace string, network agentnetwork.Policy) (Config, error) { +func ConfigureLocal(config Config, stateRoot, root, workspace string) (Config, error) { if !filepath.IsAbs(stateRoot) || filepath.Clean(stateRoot) != stateRoot { return Config{}, fmt.Errorf("claudesdk: state root must be a clean absolute directory") } config.StateDir = filepath.Join(stateRoot, "runtime", "claude-sdk", "history") config.Workspace = &WorkspaceConfig{ - Directory: workspace, PublicDirectory: workspace, NetworkAccess: network.Access, AllowedDomains: network.Hosts(), + Directory: workspace, HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), } - if network.Validate() != nil { - return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") - } for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} { if err := os.MkdirAll(dir, 0700); err != nil { return Config{}, err diff --git a/apps/daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go index 797562ebc..951fa0ae8 100644 --- a/apps/daemon/internal/agent/claudesdk/local_test.go +++ b/apps/daemon/internal/agent/claudesdk/local_test.go @@ -2,69 +2,30 @@ package claudesdk import ( "encoding/json" - "os" "path/filepath" "slices" "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) -func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { +func TestLocalWorkspaceBindingAndRequiredHistory(t *testing.T) { config := workspaceFixture(t) - config.Workspace.PublicDirectory = config.Workspace.Directory - config.Workspace.NetworkAccess = "enabled" req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: config.Workspace.Directory} + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: config.Workspace.Directory} req.RequireExistingNativeSession = true start, _, err := prepare(config, req) - if err != nil || !start.RequireHistory || start.Workspace.NetworkAccess != "enabled" { + if err != nil || !start.RequireHistory { t.Fatal(start, err) } - req.LocalEnvironment.NetworkAccess = "disabled" - if _, _, err := prepare(config, req); err == nil { - t.Fatal("accepted different Runtime network policy") - } - req.LocalEnvironment.NetworkAccess = "enabled" - config.Workspace.PublicDirectory = config.Workspace.HomeDir - if _, _, err := prepare(config, req); err == nil { - t.Fatal("accepted a different public workspace") - } - alias := filepath.Join(filepath.Dir(config.Workspace.Directory), "alias") - if err := os.Symlink(config.Workspace.Directory, alias); err != nil { - t.Fatal(err) - } - config.Workspace.PublicDirectory = alias - if _, _, err := prepare(config, req); err != nil { - t.Fatal("same workspace alias rejected", err) - } - -} - -func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "restricted" - config.Workspace.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} - req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted", AllowedDomains: []string{"api.example.com", "EXAMPLE.COM"}, WorkspaceRoot: config.Workspace.Directory} - if _, _, err := prepare(config, req); err == nil { - t.Fatal("Runtime must not promise inner network isolation") - } - for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { - req.LocalEnvironment.AllowedDomains = domains - if _, _, err := prepare(config, req); err == nil { - t.Fatal("different policy entered bound Runtime", domains) - } - } } func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { config := workspaceFixture(t) original := slices.Clone(config.Env) req := workspaceRequest() - req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/anthropic", "api_key": "selected-secret"} + req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/anthropic", "api_key": "selected-secret", "context_window": 64000, "max_output_tokens": 4096} start, env, err := prepare(config, req) if err != nil { t.Fatal(err) @@ -76,6 +37,9 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") { t.Fatal("provider selection was not exclusive") } + if !slices.Contains(env, "CLAUDE_CODE_MAX_CONTEXT_TOKENS=64000") || !slices.Contains(env, "CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096") { + t.Fatal("token limits were not native") + } for _, value := range []any{nil, "secret", map[string]any{"protocol": "anthropic", "base_url": "http://provider.example", "api_key": "secret"}, map[string]any{"protocol": "anthropic", "base_url": "https://user:pass@provider.example", "api_key": "secret"}} { req.AgentOptions["model_provider"] = value if _, _, err := prepare(config, req); err == nil || strings.Contains(err.Error(), "secret") { @@ -87,11 +51,11 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { func TestLocalHistorySurvivesChangingInstanceRoot(t *testing.T) { config := workspaceFixture(t) retained, instance := t.TempDir(), t.TempDir() - first, err := ConfigureLocal(config, retained, instance, config.Workspace.Directory, agentnetwork.Policy{Access: "enabled"}) + first, err := ConfigureLocal(config, retained, instance, config.Workspace.Directory) if err != nil { t.Fatal(err) } - second, err := ConfigureLocal(config, retained, t.TempDir(), config.Workspace.Directory, agentnetwork.Policy{Access: "enabled"}) + second, err := ConfigureLocal(config, retained, t.TempDir(), config.Workspace.Directory) if err != nil { t.Fatal(err) } @@ -101,7 +65,7 @@ func TestLocalHistorySurvivesChangingInstanceRoot(t *testing.T) { if first.Workspace.HomeDir == second.Workspace.HomeDir || first.Workspace.ScratchDir == second.Workspace.ScratchDir { t.Fatal("instance HOME or scratch retained across compute") } - if _, err := ConfigureLocal(config, "relative", instance, config.Workspace.Directory, agentnetwork.Policy{Access: "enabled"}); err == nil { + if _, err := ConfigureLocal(config, "relative", instance, config.Workspace.Directory); err == nil { t.Fatal("invalid state root accepted") } } diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index b193136d7..c71b83b24 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -12,10 +12,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) var mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -var mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) func validateMCP(req proto.PromptRequestPayload) error { if req.MCPHTTPServers == nil { @@ -31,35 +31,39 @@ func validateMCP(req proto.PromptRequestPayload) error { func validateMCPServers(servers []proto.MCPHTTPServer) error { labels := map[string]bool{} for _, server := range servers { + if server.Stdio != nil { + if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] { + return fmt.Errorf("claudesdk: unsupported MCP stdio declaration") + } + labels[server.ServerLabel] = true + continue + } endpoint, err := url.Parse(server.ServerURL) if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || - strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" { + strings.Contains(server.ServerURL, "#") || endpoint.Opaque != "" { return fmt.Errorf("claudesdk: unsupported HTTP MCP declaration") } if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential") } - labels[server.ServerLabel] = true - if server.AllowedTools != nil { - for _, name := range *server.AllowedTools { - if !mcpTool.MatchString(name) { - return fmt.Errorf("claudesdk: unsupported MCP tool allowlist") - } - } + if declaration.ExpandsEnvironment(server) { + return fmt.Errorf("claudesdk: MCP URLs and header values cannot contain ${") } + labels[server.ServerLabel] = true } return nil } -// Only generated references cross the private bridge; secrets stay in the owned -// process environment and are expanded by the native HTTP client. +// Bearer tokens cross the private bridge only as generated references; they stay +// in the owned process environment and are expanded by the native HTTP client. type mcpHTTPServer struct { - ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url,omitempty"` - AllowedTools *[]string `json:"allowed_tools"` - Required bool `json:"required,omitempty"` - BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url,omitempty"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` + BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []string) { @@ -69,7 +73,7 @@ func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []st servers := make([]mcpHTTPServer, len(*declarations)) var env []string for i, declaration := range *declarations { - server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} + server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required, Headers: declaration.Headers} if declaration.AllowedTools != nil { tools := append([]string{}, (*declaration.AllowedTools)...) server.AllowedTools = &tools diff --git a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 21997b158..69a52785b 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -75,7 +75,7 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { t.Fatal("invalid credential wrote execution state", err) } } - for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://example.invalid/mcp?", "https://user:secret@example.invalid/mcp"} { + for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://user:secret@example.invalid/mcp"} { token := "synthetic-token" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: url, BearerToken: &token}} if err := validateMCP(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil { diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go index 5c9e1db31..06562bb78 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -2,6 +2,7 @@ package claudesdk import ( "fmt" + "path/filepath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" @@ -12,8 +13,11 @@ import ( // transport projection crosses this private bridge, never package configuration. type environmentMCPServer struct { mcpHTTPServer - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` + Command string `json:"command,omitempty"` + // A pointer keeps an empty stdio argument list, which the bridge requires. + Args *[]string `json:"args,omitempty"` + // Declared marks a public stdio server: its command is the caller's, not the installed launcher. + Declared bool `json:"declared,omitempty"` } func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, []string, error) { @@ -27,17 +31,22 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, if !mcpLabel.MatchString(binding.ServerLabel) || binding.ServerLabel == "functions" { return nil, nil, fmt.Errorf("claudesdk: unsupported MCP identity") } + if process := binding.Process; process != nil { + // Native starts stdio servers in its workspace, which the declared + // directory names directly or through the hosted /workspace alias. + if req.LocalEnvironment == nil || filepath.Clean(process.Cwd) != req.LocalEnvironment.WorkspaceDirectory { + return nil, nil, fmt.Errorf("claudesdk: MCP stdio cwd must be the workspace directory") + } + args := append([]string{}, process.Args...) + servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel, Required: binding.Required}, Command: process.Command, Args: &args, Declared: true}) + continue + } if binding.Stdio != nil { command, args := localworkspace.MCPStdioCommand(*binding.Stdio) - servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: args}) + servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: &args}) continue } - // Native header interpolation and redirect behavior cannot preserve literal - // custom-header authority. Reject this unqualified combination explicitly. - if len(binding.HTTPHeaders) != 0 { - return nil, nil, fmt.Errorf("claudesdk: literal MCP HTTP headers are not supported") - } - declarations := []proto.MCPHTTPServer{{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required, BearerToken: binding.BearerToken}} + declarations := []proto.MCPHTTPServer{{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required, BearerToken: binding.BearerToken, Headers: binding.HTTPHeaders}} if err := validateMCPServers(declarations); err != nil { return nil, nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index ab84b3b9e..d1c87211e 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -3,6 +3,7 @@ package claudesdk import ( "encoding/json" "os" + "slices" "strings" "testing" @@ -12,11 +13,10 @@ import ( func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) { config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" req := workspaceRequest() token := "selected-user-token" t.Setenv("MCP_TOKEN", "unselected-native-token") - req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", NetworkAccess: "enabled", WorkspaceRoot: config.Workspace.Directory, MCP: []proto.EnvironmentMCP{ + req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", WorkspaceRoot: config.Workspace.Directory, MCP: []proto.EnvironmentMCP{ {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, }} @@ -29,7 +29,7 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) } stdio := start.Workspace.MCP[0] executable, _ := os.Executable() - if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { + if stdio.Command != executable || stdio.Args == nil || !slices.Equal(*stdio.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/local", "local"}) { t.Fatal("stdio bypassed the shared installed entry") } raw, _ := json.Marshal(start) @@ -52,11 +52,10 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { for _, mutate := range []func(*proto.LocalEnvironment){ - func(e *proto.LocalEnvironment) { e.NetworkAccess = "restricted" }, func(e *proto.LocalEnvironment) { e.MCP[0].Server.Name = "functions" }, func(e *proto.LocalEnvironment) { e.MCP = append(e.MCP, e.MCP[0]) }, func(e *proto.LocalEnvironment) { e.MCP[0].Server.Type = "sse" }, - func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "${HOME}"} }, func(e *proto.LocalEnvironment) { e.MCP[0].Server.BearerTokenEnvVar = "MISSING" }, func(e *proto.LocalEnvironment) { token := "token" @@ -64,7 +63,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { e.MCP[0].Server.URL = "http://example.invalid/mcp" }, } { - environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} + environment := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} mutate(environment) if _, _, err := prepareRuntimeMCP(proto.PromptRequestPayload{LocalEnvironment: environment}); err == nil { t.Fatal("unsupported declaration accepted") @@ -90,3 +89,20 @@ func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { t.Fatal("undeclared observation accepted") } } + +// Native starts a stdio server in the directory of its own process, so the +// declared cwd must be the declared workspace directory. +func TestPublicMCPStdioStartsInTheWorkspaceDirectory(t *testing.T) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", + Stdio: &proto.MCPStdio{Command: "run", Cwd: "/workspace", EnvVars: []string{"TOKEN"}}}} + req := proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: "/workspace", WorkspaceRoot: "/environment/workspace"}, MCPHTTPServers: &servers} + projected, _, err := prepareRuntimeMCP(req) + raw, _ := json.Marshal(projected) + if err != nil || string(raw) != `[{"server_label":"local","allowed_tools":null,"command":"run","args":[],"declared":true}]` { + t.Fatal("public stdio server was not projected", string(raw), err) + } + req.LocalEnvironment.WorkspaceDirectory = "/elsewhere" + if _, _, err := prepareRuntimeMCP(req); err == nil { + t.Fatal("stdio cwd outside the workspace directory was accepted") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index a8ad18877..f219d101d 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -35,7 +35,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { case "url-auth": servers[0].ServerURL = "https://user:secret@example.invalid/mcp" case "query": - servers[0].ServerURL += "?" + servers[0].ServerURL += "?tenant=a" case "wildcard": tools = []string{"*"} servers[0].AllowedTools = &tools @@ -47,7 +47,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { req.DisableExecutionEnvironment = false } start, _, err := prepare(config, req) - valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" + valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" || mode == "query" || mode == "wildcard" if (err == nil) != valid { t.Fatalf("unexpected admission: %v", err) } diff --git a/apps/daemon/internal/agent/claudesdk/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go index feaf594e7..7a6c0ffa1 100644 --- a/apps/daemon/internal/agent/claudesdk/messages_test.go +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -15,12 +15,12 @@ import ( ) func TestMessageObservations(t *testing.T) { - for _, mode := range []string{"messages-success", "messages-partial", "messages-unrequested", "messages-missing-id", "messages-invalid-snapshot"} { + for _, mode := range []string{"messages-success", "messages-partial", "messages-missing-id", "messages-invalid-snapshot"} { t.Run(mode, func(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"), ObserveMessages: mode != "messages-unrequested", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) @@ -60,7 +60,7 @@ func TestMessageObservations(t *testing.T) { if len(events) != 4 || events[0].Type != proto.TypeOutputMessage || events[1].Type != proto.TypeDelta { t.Fatalf("lost partial output: %+v", events) } - case "messages-unrequested", "messages-missing-id": + case "messages-missing-id": if len(events) != 2 { t.Fatalf("invalid bridge observation escaped: %+v", events) } @@ -77,13 +77,7 @@ func runMessageHelper(request startRequest, mode string, emit func(bridgeEvent)) emit(bridgeEvent{Type: "delta", Delta: "unidentified"}) return } - if mode != "messages-unrequested" && !request.ObserveMessages { - os.Exit(4) - } message("message-1", "in_progress", nil) - if mode == "messages-unrequested" { - return - } emit(bridgeEvent{Type: "delta", ItemID: "message-1", Delta: "partial"}) if mode == "messages-partial" { emit(bridgeEvent{Type: "error", Code: "execution_failed"}) diff --git a/apps/daemon/internal/agent/claudesdk/native_controls_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/native_controls_live_linux_test.go new file mode 100644 index 000000000..d3170948f --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/native_controls_live_linux_test.go @@ -0,0 +1,87 @@ +//go:build linux + +package claudesdk + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// TestLiveClaudeSDKNativeControls proves, on the real SDK and provider, a whitespace-only message +// and live web search on environment none. +func TestLiveClaudeSDKNativeControls(t *testing.T) { + entrypoint, keyFile, proofRoot := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") + endpoint, model := os.Getenv("OAC_TEST_CLAUDE_SDK_BASE_URL"), os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL") + if entrypoint == "" || keyFile == "" || endpoint == "" || model == "" || !filepath.IsAbs(proofRoot) { + t.Skip("real SDK/provider acceptance requires an entrypoint, a private key file, an endpoint, a model and an absolute proof directory") + } + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(proofRoot, 0o700); err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(proofRoot, "claude-controls-") + if err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=" + endpoint, "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), + "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=" + model, "ANTHROPIC_DEFAULT_OPUS_MODEL=" + model, "ANTHROPIC_DEFAULT_HAIKU_MODEL=" + model, + }} + run := func(t *testing.T, prompt proto.MessageInput, configure func(*proto.PromptRequestPayload)) (items []string, content, failure string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + req := proto.PromptRequestPayload{RunID: uuid.NewString(), DisableExecutionEnvironment: true, DisableSubagents: true, Input: prompt, + ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, + AgentOptions: map[string]any{"model": model, "system_prompt": "Answer briefly."}} + configure(&req) + out := make(chan proto.Envelope, 64) + running, err := startSingleTurn(ctx, config, req, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + for event := range out { + items = append(items, event.Type) + switch event.Type { + case proto.TypeError: + var payload proto.ErrorPayload + _ = json.Unmarshal(event.Payload, &payload) + failure = payload.Error + case proto.TypeDone: + var payload proto.DonePayload + _ = json.Unmarshal(event.Payload, &payload) + content = payload.Content + } + } + return + } + t.Run("whitespace_only_message", func(t *testing.T) { + items, content, failure := run(t, proto.TextInput(" \n\t"), func(*proto.PromptRequestPayload) {}) + if failure != "" || content == "" { + t.Fatalf("whitespace-only message failed: items=%v failure=%q", items, failure) + } + }) + t.Run("live_search_on_none", func(t *testing.T) { + items, content, failure := run(t, proto.TextInput("Search the web for the current stable version of Go and report it in one sentence."), func(req *proto.PromptRequestPayload) { + req.ExecutionControls.WebSearch = &proto.WebSearchControl{Mode: "live", ContextSize: "medium"} + }) + if failure != "" || content == "" || !slices.Contains(items, proto.TypeToolCall) { + t.Fatalf("live search failed: items=%v failure=%q", items, failure) + } + }) +} diff --git a/apps/daemon/internal/agent/claudesdk/native_model_options.go b/apps/daemon/internal/agent/claudesdk/native_model_options.go index 65ea8248c..b378e2b12 100644 --- a/apps/daemon/internal/agent/claudesdk/native_model_options.go +++ b/apps/daemon/internal/agent/claudesdk/native_model_options.go @@ -9,14 +9,11 @@ type nativeModelOptions struct { Thinking map[string]any `json:"thinking,omitempty"` } -func compileNativeModelOptions(config proto.HarnessConfig) *nativeModelOptions { - if len(config) == 0 { +func compileNativeModelOptions(config proto.HarnessConfig, effort string) *nativeModelOptions { + if len(config) == 0 && effort == "" { return nil } - options := &nativeModelOptions{} - if value, exists := config["effort"]; exists { - options.Effort = value.(string) - } + options := &nativeModelOptions{Effort: effort} if value, exists := config["thinking"]; exists { options.Thinking = value.(map[string]any) } diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 81f968eff..665896f47 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -25,6 +25,8 @@ type subagentOptions struct { type startRequest struct { NativeModelOptions *nativeModelOptions `json:"native_model_options,omitempty"` ToolSearch bool `json:"tool_search,omitempty"` + WebSearch string `json:"web_search,omitempty"` + ReasoningSummary bool `json:"reasoning_summary,omitempty"` Subagents *subagentOptions `json:"subagents,omitempty"` OutputFormat *proto.OutputFormat `json:"output_format,omitempty"` Type string `json:"type"` @@ -33,7 +35,6 @@ type startRequest struct { SystemPrompt string `json:"system_prompt"` Cwd string `json:"cwd"` Resume string `json:"resume,omitempty"` - ObserveMessages bool `json:"observe_messages,omitempty"` Functions []proto.FunctionTool `json:"functions,omitempty"` MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` Workspace *workspaceProfile `json:"workspace,omitempty"` @@ -53,36 +54,38 @@ 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} + start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, Functions: req.FunctionTools} fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) } - modelConfiguration, err := harnessconfiguration.Configuration().Prepare(req.AgentOptions) + modelConfiguration, err := harnessconfiguration.Declaration.Configuration.Prepare(req.AgentOptions) if err != nil { return startRequest{}, nil, err } - start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig) + if err := harnessconfiguration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { + return fail(err.Error()) + } + effort := "" + if req.ExecutionControls != nil { + effort = req.ExecutionControls.ReasoningEffort + } + start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig, effort) if err := req.ValidateToolSearch(true); err != nil { return startRequest{}, nil, err } - if req.ToolSearch { - if (req.LocalEnvironment != nil && (len(req.LocalEnvironment.Skills) != 0 || len(req.LocalEnvironment.MCP) != 0)) || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) { - return fail("tool discovery requires the single-agent text/function profile") - } - start.ToolSearch = true - } + start.ToolSearch = req.ToolSearch if err := validateMCP(req); err != nil { return startRequest{}, nil, err } - // Search is disabled by the fixed native tool profile. Medium selects the - // SDK's default text generation; it has no native verbosity-level option. - if controls := req.ExecutionControls; controls != nil && (controls.WebSearch != "disabled" || controls.TextVerbosity != "medium") { - return fail("execution controls require disabled web search and medium text verbosity") + // Without a search control Claude Code keeps its native web tools. + if controls := req.ExecutionControls; controls != nil && controls.WebSearch != nil { + start.WebSearch = controls.WebSearch.Mode } + start.ReasoningSummary = req.ExecutionControls != nil && req.ExecutionControls.ReasoningSummary != "" if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { format := req.ExecutionControls.OutputFormat - if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) { - return fail("structured output requires the qualified message-observing single-agent function profile") + if format.Type != "json_schema" { + return fail("structured output requires a JSON schema format") } if err := proto.ValidateBinary64Schema(format.Schema); err != nil { return startRequest{}, nil, err @@ -90,8 +93,8 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR start.OutputFormat = format } if req.ObserveSubagentIdentities { - if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) { - return fail("subagent execution does not support this tool combination") + if req.DisableSubagents { + return fail("subagent observation conflicts with disabled subagents") } limit := 6 if req.MaxConcurrentSubagents != nil { @@ -145,7 +148,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return startRequest{}, nil, err } start.Workspace = profile - start.Cwd = workspaceCwd(config.Workspace) + start.Cwd = config.Workspace.Directory return start, env, nil } if req.LocalEnvironment != nil || req.RequireExistingNativeSession { @@ -161,7 +164,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR } } env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") + env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp")) projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) if err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 081a199b9..67e60abc9 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -111,12 +111,8 @@ func runPreparationHelper() { return } emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - if request.ObserveMessages { - text := "completed" - emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: "native-message", Status: "completed", Text: &text}}) - } else { - emit(bridgeEvent{Type: "delta", Delta: "partial"}) - } + text := "completed" + emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: "native-message", Status: "completed", Text: &text}}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "completed"}) diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 5c03d23c5..8040a0b4d 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -117,7 +117,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} case "controls": - req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} + req.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "medium"}} } if _, err := NewExecutorFactory(config)(t.Context(), req); err == nil { t.Fatal("invalid preparation was accepted") diff --git a/apps/daemon/internal/agent/claudesdk/provider.go b/apps/daemon/internal/agent/claudesdk/provider.go index 18114ea1e..22b86dcd6 100644 --- a/apps/daemon/internal/agent/claudesdk/provider.go +++ b/apps/daemon/internal/agent/claudesdk/provider.go @@ -2,6 +2,7 @@ package claudesdk import ( "sort" + "strconv" "strings" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" @@ -9,7 +10,7 @@ import ( // Validate the frozen native provider before producing launch variables. func providerEnvironment(value any) ([]string, error) { - provider, err := harnessconfiguration.Configuration().ParseProvider(value) + provider, err := harnessconfiguration.Declaration.Configuration.ParseProvider(value) if err != nil { return nil, err } @@ -23,7 +24,15 @@ func providerEnvironment(value any) ([]string, error) { headers = append(headers, name+": "+provider.Headers[name]) } // An explicit empty value clears any previous native header selection. - return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey, "ANTHROPIC_CUSTOM_HEADERS=" + strings.Join(headers, "\n")}, nil + env := []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey, "ANTHROPIC_CUSTOM_HEADERS=" + strings.Join(headers, "\n")} + // Native uses the context window only for a model it does not know. + if provider.ContextWindow > 0 { + env = append(env, "CLAUDE_CODE_MAX_CONTEXT_TOKENS="+strconv.Itoa(int(provider.ContextWindow))) + } + if provider.MaxOutputTokens > 0 { + env = append(env, "CLAUDE_CODE_MAX_OUTPUT_TOKENS="+strconv.Itoa(int(provider.MaxOutputTokens))) + } + return env, nil } func withProvider(env, provider []string) []string { @@ -33,7 +42,7 @@ func withProvider(env, provider []string) []string { out := make([]string, 0, len(env)+len(provider)) for _, entry := range env { key, _, _ := strings.Cut(entry, "=") - if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" && key != "ANTHROPIC_CUSTOM_HEADERS" { + if key != "ANTHROPIC_CUSTOM_HEADERS" && key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" && key != "CLAUDE_CODE_MAX_CONTEXT_TOKENS" && key != "CLAUDE_CODE_MAX_OUTPUT_TOKENS" { out = append(out, entry) } } diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index a574a5009..494e32727 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -19,7 +19,7 @@ func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { controls *proto.ExecutionControls }{ {"environment", true, false, nil}, {"subagents", false, true, nil}, {"both", true, true, nil}, - {"execution-controls", true, true, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}}, + {"execution-controls", true, true, &proto.ExecutionControls{TextVerbosity: "medium"}}, } { t.Run(test.name, func(t *testing.T) { root := t.TempDir() diff --git a/apps/daemon/internal/agent/claudesdk/searches.go b/apps/daemon/internal/agent/claudesdk/searches.go new file mode 100644 index 000000000..ffd9f85e4 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/searches.go @@ -0,0 +1,52 @@ +package claudesdk + +import ( + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type searchState struct { + calls map[string]proto.ToolObservation +} + +func (c *searchState) receive(event bridgeEvent, start startRequest, sessionID string, emit func(string, any)) error { + n := event.Observation + if start.Workspace == nil && start.WebSearch != "live" || sessionID == "" || event.SessionID != sessionID || event.ID == "" || n == nil || + n.Kind != "web_search" || n.Action == nil || (n.Action.Type != "search" && n.Action.Type != "open_page") || + (n.Action.Type == "search") != (n.Action.Query != nil) || (n.Action.Type == "open_page") != (n.Action.URL != nil) || + n.Name != "" || n.Command != "" || n.Cwd != nil || n.ExitCode != nil || n.DurationMS != nil || n.Server != "" || + len(n.Arguments) != 0 || len(n.Output) != 0 || len(n.Error) != 0 || n.Content != nil { + return fmt.Errorf("claudesdk: invalid web search observation") + } + previous, exists := c.calls[event.ID] + if (event.Stage == "before" && (exists || n.Status != "in_progress")) || + (event.Stage == "after" && (!exists || previous.Status != "in_progress" || + (n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete"))) || + (event.Stage != "before" && event.Stage != "after") { + return fmt.Errorf("claudesdk: inconsistent web search observation") + } + c.calls[event.ID] = *n + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "web_search", Stage: event.Stage, Observation: n}) + return nil +} + +func (c *searchState) complete() bool { + for _, call := range c.calls { + if call.Status == "in_progress" { + return false + } + } + return true +} + +func (c *searchState) close(emit func(string, any)) { + for id, call := range c.calls { + if call.Status != "in_progress" { + continue + } + call.Status = "incomplete" + c.calls[id] = call + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "web_search", Stage: "after", Observation: &call}) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/searches_test.go b/apps/daemon/internal/agent/claudesdk/searches_test.go new file mode 100644 index 000000000..019a5a16d --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/searches_test.go @@ -0,0 +1,59 @@ +package claudesdk + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func searchEvent(id, stage, status string, action *proto.ToolWebSearchAction) bridgeEvent { + return bridgeEvent{Type: "search_observation", SessionID: "native-session", ID: id, Stage: stage, + Observation: &proto.ToolObservation{Kind: "web_search", Status: status, Action: action}} +} + +func TestSearchObservationLifecycle(t *testing.T) { + state := searchState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}} + var events []proto.ToolCallPayload + emit := func(kind string, payload any) { events = append(events, payload.(proto.ToolCallPayload)) } + query, url := "agents api", "https://example.com" + search := &proto.ToolWebSearchAction{Type: "search", Query: &query} + if err := state.receive(searchEvent("one", "before", "in_progress", search), start, "native-session", emit); err != nil || state.complete() { + t.Fatal("search was not pending", err) + } + if err := state.receive(searchEvent("one", "before", "in_progress", search), start, "native-session", emit); err == nil { + t.Fatal("duplicate search accepted") + } + if err := state.receive(searchEvent("one", "after", "completed", search), start, "native-session", emit); err != nil || !state.complete() { + t.Fatal("search did not complete", err) + } + open := &proto.ToolWebSearchAction{Type: "open_page", URL: &url} + if err := state.receive(searchEvent("two", "before", "in_progress", open), start, "native-session", emit); err != nil { + t.Fatal(err) + } + state.close(emit) + if state.calls["two"].Status != "incomplete" || len(events) != 4 || events[3].Observation.Action.URL == nil { + t.Fatal("unfinished page open was not closed", events) + } + for name, event := range map[string]bridgeEvent{ + "action": searchEvent("bad", "before", "in_progress", &proto.ToolWebSearchAction{Type: "find_in_page", Pattern: &query}), + "missing": searchEvent("bad", "before", "in_progress", &proto.ToolWebSearchAction{Type: "search"}), + "session": {Type: "search_observation", SessionID: "other", ID: "bad", Stage: "before", Observation: &proto.ToolObservation{Kind: "web_search", Status: "in_progress", Action: search}}, + } { + if err := state.receive(event, start, "native-session", emit); err == nil { + t.Fatal("invalid observation accepted", name) + } + } +} + +func TestSearchObservationOnEnvironmentNoneNeedsLiveSearch(t *testing.T) { + query := "agents api" + event := searchEvent("one", "before", "in_progress", &proto.ToolWebSearchAction{Type: "search", Query: &query}) + emit := func(string, any) {} + for _, mode := range []string{"", "live"} { + state := searchState{calls: map[string]proto.ToolObservation{}} + if err := state.receive(event, startRequest{WebSearch: mode}, "native-session", emit); (err == nil) != (mode == "live") { + t.Fatalf("mode %q: %v", mode, err) + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index f252ce14f..b5e55ee34 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -41,23 +41,25 @@ type bridgeEvent struct { Reason string `json:"reason"` Protocol int `json:"protocol"` - Fact json.RawMessage `json:"fact"` - InputID string `json:"input_id"` - ResultID string `json:"result_id"` - Usage json.RawMessage `json:"usage,omitempty"` - Type string `json:"type"` - Delta string `json:"delta"` - SessionID string `json:"session_id"` - Text string `json:"text"` - Code string `json:"code"` - ItemID string `json:"item_id"` - Message *proto.OutputMessagePayload `json:"message"` - Call *proto.FunctionCallPayload `json:"call"` - CallID string `json:"call_id"` - DeliveryID string `json:"delivery_id"` - ID string `json:"id"` - Stage string `json:"stage"` - Observation *proto.ToolObservation `json:"observation"` + Fact json.RawMessage `json:"fact"` + InputID string `json:"input_id"` + ResultID string `json:"result_id"` + Usage json.RawMessage `json:"usage,omitempty"` + Type string `json:"type"` + Delta string `json:"delta"` + SessionID string `json:"session_id"` + Text string `json:"text"` + Code string `json:"code"` + ItemID string `json:"item_id"` + Message *proto.OutputMessagePayload `json:"message"` + Reasoning *proto.ReasoningPayload `json:"reasoning"` + Call *proto.FunctionCallPayload `json:"call"` + Withdrawn *proto.FunctionCallWithdrawnPayload `json:"withdrawn"` + CallID string `json:"call_id"` + DeliveryID string `json:"delivery_id"` + ID string `json:"id"` + Stage string `json:"stage"` + Observation *proto.ToolObservation `json:"observation"` } func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { @@ -65,7 +67,9 @@ 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}) + // Node's fetch honours the operator's proxy only when asked; unlike NODE_USE_ENV_PROXY, the flag stays with + // the bridge, so native tools run with the environment they would have locally. + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"--use-env-proxy", config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index 7c0f094c2..e885c51a8 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -42,9 +42,6 @@ func TestTextFactoryCompletionAndFailures(t *testing.T) { case proto.TypeDelta: var payload proto.DeltaPayload _ = json.Unmarshal(event.Payload, &payload) - if payload.ItemID != "" { - t.Fatal("ordinary deltas acquired message identity") - } deltas += payload.Delta case proto.TypeOutputMessage: t.Fatal("ordinary requests acquired message observations") @@ -82,7 +79,7 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: map[string]any{"model": "fake"}} switch kind { case "execution-controls": - request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "low"} + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "low"} case "tool": request.FunctionTools = []proto.FunctionTool{{}} case "outside": @@ -167,7 +164,7 @@ func runSDKHelper() { encode(bridgeEvent{Type: "error", Code: "execution_failed"}) return } - encode(bridgeEvent{Type: "delta", Delta: "partial"}) + encode(bridgeEvent{Type: "delta", ItemID: "item", Delta: "partial"}) id := request.Resume if mode == "wrong-resume" { id = "different-session" @@ -177,7 +174,7 @@ func runSDKHelper() { os.Exit(7) } if mode == "after-result" { - encode(bridgeEvent{Type: "delta", Delta: "too late"}) + encode(bridgeEvent{Type: "delta", ItemID: "item", Delta: "too late"}) return } time.Sleep(50 * time.Millisecond) diff --git a/apps/daemon/internal/agent/claudesdk/steering.go b/apps/daemon/internal/agent/claudesdk/steering.go index c64b55d72..4db08b1b3 100644 --- a/apps/daemon/internal/agent/claudesdk/steering.go +++ b/apps/daemon/internal/agent/claudesdk/steering.go @@ -24,15 +24,8 @@ type steeringState struct { seen map[string]bool } -var _ agent.Steerer = (*session)(nil) - -// Steer waits for native consumption, which may occur in a later native turn -// within this one SDK query. A completed stdin write is not a receipt. -func (s *session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return s.SteerWithReceipt(ctx, input, nil) -} - -// SteerWithReceipt separates a complete bridge write from native consumption. +// SteerWithReceipt separates a complete bridge write from native consumption, +// which may occur in a later native turn within this one SDK query. func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { select { case <-s.cancelOutput: @@ -91,9 +84,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP s.invalidate() return fmt.Errorf("claudesdk: input transport failed") } - if written != nil { - written() - } + written() select { case err := <-pending.receipt: return err diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go index b59f5107f..2313476b6 100644 --- a/apps/daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -67,7 +67,7 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { } }) } else { - reply <- s.Steer(receiptCtx, input) + reply <- s.SteerWithReceipt(receiptCtx, input, func() {}) } }() if mode == "blocked-write" { @@ -144,7 +144,7 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { if mode == "duplicate-usage" && measurements != 1 { t.Fatal("duplicate measurement was published") } - if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { + if err := s.SteerWithReceipt(ctx, input, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal("completed execution accepted input", err) } if _, err := s.AwaitSettlement(ctx); err != nil && (mode == "success" || mode == "phased" || mode == "timeout") { @@ -159,18 +159,18 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { func TestSteeringDoesNotSendBeforeReadiness(t *testing.T) { s := &session{process: &clirunner.Process{}} - if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}); !errors.Is(err, agent.ErrSteeringNotReady) { + if err := s.SteerWithReceipt(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}, func() {}); !errors.Is(err, agent.ErrSteeringNotReady) { t.Fatal(err) } s.stopSteering() - if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}); !errors.Is(err, agent.ErrSteeringInactive) { + if err := s.SteerWithReceipt(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal(err) } } func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - emit(bridgeEvent{Type: "delta", Delta: "ready"}) + emit(bridgeEvent{Type: "delta", ItemID: "item", Delta: "ready"}) if mode == "steering-blocked-write" { time.Sleep(time.Hour) return diff --git a/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go new file mode 100644 index 000000000..586c4c0b6 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go @@ -0,0 +1,197 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// liveSubagentConfig reads the private provider settings of the real-provider Subagent tests. +func liveSubagentConfig(t *testing.T) (config Config, key []byte, root string) { + entrypoint, keyFile, proofRoot := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") + endpoint, model := os.Getenv("OAC_TEST_CLAUDE_SDK_BASE_URL"), os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL") + if entrypoint == "" || keyFile == "" || proofRoot == "" || endpoint == "" || model == "" { + t.Skip("real SDK/provider acceptance requires explicit entrypoint, key file, proof directory, base URL and model") + } + key, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + key = bytes.TrimSpace(key) + root, err = os.MkdirTemp(proofRoot, "claude-subagent-") + if err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", root) + return Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=" + endpoint, "ANTHROPIC_AUTH_TOKEN=" + string(key), "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=" + model, "ANTHROPIC_DEFAULT_OPUS_MODEL=" + model, "ANTHROPIC_DEFAULT_HAIKU_MODEL=" + model, + }}, key, root +} + +// saveEvidence stores the observed events of a live run, unless they would contain the provider key. +func saveEvidence(t *testing.T, root string, key []byte, evidence any) { + encoded, _ := json.MarshalIndent(evidence, "", " ") + if bytes.Contains(encoded, key) { + t.Error("evidence contains the provider key") + return + } + _ = os.WriteFile(filepath.Join(root, "evidence.json"), encoded, 0o600) + t.Logf("evidence: %s/evidence.json", root) +} + +// TestLiveClaudeSDKNativeSubagent runs one Turn in which the model spawns a native general-purpose Subagent with a per-call model. +func TestLiveClaudeSDKNativeSubagent(t *testing.T) { + config, key, root := liveSubagentConfig(t) + model := os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL") + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + out := make(chan proto.Envelope, 256) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), ObserveSubagentIdentities: true, + Input: proto.TextInput("Call the Agent tool exactly once with subagent_type \"general-purpose\", model \"sonnet\", name \"helper\" and a prompt asking it to reply with the single word PONG. Then report what it replied."), + AgentOptions: map[string]any{"model": model}} + turn, err := startSingleTurn(ctx, config, request, out) + if err != nil { + t.Fatal(err) + } + defer turn.Cancel(context.Background()) + counts := map[string]int{} + var coordination []map[string]any + var events []proto.Envelope + defer func() { + saveEvidence(t, root, key, map[string]any{"counts": counts, "coordination": coordination, "events": events}) + }() + for event := range out { + events = append(events, event) + counts[event.Type]++ + switch event.Type { + case proto.TypeError: + t.Fatalf("native failure: %s", event.Payload) + case proto.TypeSubagentCoordination: + var fact map[string]any + _ = json.Unmarshal(event.Payload, &fact) + coordination = append(coordination, fact) + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + if counts[proto.TypeSubagentIdentity] != 1 || counts[proto.TypeSubagentTurn] < 2 || counts[proto.TypeSubagentItem] == 0 { + t.Fatal("native Subagent was not observed") + } + var spawn map[string]any + for _, fact := range coordination { + if fact["kind"] == "create_subagent_call" { + spawn = fact + } + } + if spawn == nil || spawn["status"] != "completed" || spawn["model"] != "sonnet" { + t.Fatalf("spawn coordination did not report the requested model: %v", spawn) + } +} + +// TestLiveClaudeSDKSubagentFunctionCall runs one Turn in which a native Subagent calls the Agent's function. The call reaches Core with the child's +// identity after the child's facts, the submitted result is confirmed by the child's own native tool result, and the Turn reports no root tool call for it. +func TestLiveClaudeSDKSubagentFunctionCall(t *testing.T) { + config, key, root := liveSubagentConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second) + defer cancel() + out := make(chan proto.Envelope, 256) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), ObserveSubagentIdentities: true, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Returns the secret code of a topic.", Parameters: json.RawMessage(`{"type":"object","properties":{"topic":{"type":"string"}},"required":["topic"]}`)}}, + Input: proto.TextInput("Call the Agent tool exactly once with subagent_type \"general-purpose\", name \"helper\" and a prompt telling it to call the mcp__functions__lookup tool with topic \"sky\" and reply with exactly the code it returned. Then report that code."), + AgentOptions: map[string]any{"model": os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL")}} + turn, err := startSingleTurn(ctx, config, request, out) + if err != nil { + t.Fatal(err) + } + defer turn.Cancel(context.Background()) + submitter := turn.(agent.FunctionResultSubmitter) + var events []proto.Envelope + var call proto.FunctionCallPayload + var tools, subagentTools, factsBefore int + submitted := make(chan error, 1) + defer func() { saveEvidence(t, root, key, events) }() + for event := range out { + events = append(events, event) + switch event.Type { + case proto.TypeError: + t.Fatalf("native failure: %s", event.Payload) + case proto.TypeSubagentIdentity, proto.TypeSubagentTurn: + if call.CallID == "" { + factsBefore++ + } + case proto.TypeToolCall: + tools++ + case proto.TypeSubagentItem: + if bytes.Contains(event.Payload, []byte(`"kind":"function"`)) { + subagentTools++ + } + case proto.TypeFunctionCall: + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + text := "CODE-4821" + result := proto.FunctionResultPayload{DeliveryID: "delivery", CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}} + go func() { submitted <- submitter.SubmitFunctionResult(ctx, result) }() + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + if call.NativeID == "" || call.TurnID == "" || factsBefore < 2 || tools != 0 || subagentTools == 0 { + t.Fatalf("child call %+v, facts before it %d, root tool calls %d, child function items %d", call, factsBefore, tools, subagentTools) + } + if err := <-submitted; err != nil { + t.Fatalf("child result was not confirmed: %v", err) + } +} + +// TestLiveClaudeSDKSubagentFunctionCallWithdrawn cancels the Turn while a Subagent's function call waits. Native stops the child, and the call is withdrawn. +func TestLiveClaudeSDKSubagentFunctionCallWithdrawn(t *testing.T) { + config, key, root := liveSubagentConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second) + defer cancel() + out := make(chan proto.Envelope, 256) + request := proto.PromptRequestPayload{RunID: uuid.NewString(), ObserveSubagentIdentities: true, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Returns the secret code of a topic.", Parameters: json.RawMessage(`{"type":"object","properties":{"topic":{"type":"string"}},"required":["topic"]}`)}}, + Input: proto.TextInput("Call the Agent tool exactly once with subagent_type \"general-purpose\" and a prompt telling it to call the mcp__functions__lookup tool with topic \"sky\" and reply with the code it returned."), + AgentOptions: map[string]any{"model": os.Getenv("OAC_TEST_CLAUDE_SDK_MODEL")}} + turn, err := startSingleTurn(ctx, config, request, out) + if err != nil { + t.Fatal(err) + } + defer turn.Cancel(context.Background()) + var events []proto.Envelope + var call, withdrawn proto.FunctionCallWithdrawnPayload + var pending proto.FunctionCallPayload + defer func() { saveEvidence(t, root, key, events) }() + for event := range out { + events = append(events, event) + switch event.Type { + case proto.TypeFunctionCall: + if err := event.DecodePayload(&pending); err != nil { + t.Fatal(err) + } + go turn.Cancel(ctx) + case proto.TypeFunctionCallWithdrawn: + if err := event.DecodePayload(&withdrawn); err != nil { + t.Fatal(err) + } + } + } + call = proto.FunctionCallWithdrawnPayload{CallID: pending.CallID, NativeID: pending.NativeID, TurnID: pending.TurnID} + if pending.NativeID == "" || withdrawn != call { + t.Fatalf("call %+v was not withdrawn: %+v", pending, withdrawn) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/subagents_test.go b/apps/daemon/internal/agent/claudesdk/subagents_test.go index 6cb3e7467..0acaef1b4 100644 --- a/apps/daemon/internal/agent/claudesdk/subagents_test.go +++ b/apps/daemon/internal/agent/claudesdk/subagents_test.go @@ -32,8 +32,6 @@ func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { for _, change := range []func(*proto.PromptRequestPayload){ func(r *proto.PromptRequestPayload) { r.DisableSubagents = true }, func(r *proto.PromptRequestPayload) { n := 0; r.MaxConcurrentSubagents = &n }, - func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "function"}} }, - func(r *proto.PromptRequestPayload) { v := []proto.MCPHTTPServer{}; r.MCPHTTPServers = &v }, } { req := workspaceRequest() req.DisableSubagents, req.ObserveSubagentIdentities = false, true diff --git a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go index 7b53ca139..873bc281f 100644 --- a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go @@ -10,14 +10,13 @@ import ( func TestSelfHostedToolEnvironment(t *testing.T) { config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" file := filepath.Join(t.TempDir(), "tool-env.json") if err := os.WriteFile(file, []byte(`{"USER_VALUE":"ready"}`), 0600); err != nil { t.Fatal(err) } t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled", WorkspaceRoot: config.Workspace.Directory} + req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace.Directory} profile, _, err := prepareWorkspace(config, req) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index dddda2cfc..e554a0581 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -4,26 +4,22 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // WorkspaceConfig binds one trusted private placement. It does not create an // isolation boundary or authorize a public Environment. The operator must place // the entire factory inside the qualified outer mount/process boundary first. // State directories must already exist. -// PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { - Directory string - PublicDirectory string - NetworkAccess string - AllowedDomains []string - HomeDir string - ScratchDir string + Directory string + HomeDir string + ScratchDir string } type workspaceProfile struct { @@ -35,8 +31,6 @@ type workspaceProfile struct { State string `json:"state"` Scratch string `json:"scratch"` EnvNames []string `json:"env_names"` - NetworkAccess string `json:"network_access,omitempty"` - AllowedDomains []string `json:"allowed_domains,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { @@ -46,9 +40,6 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if req.LocalEnvironment != nil && req.LocalEnvironment.WorkspaceRoot != config.Workspace.Directory { return nil, nil, fmt.Errorf("claudesdk: workspace root conflicts with the trusted workspace binding") } - if req.LocalEnvironment != nil && !(agentnetwork.Policy{Access: config.Workspace.NetworkAccess, AllowedDomains: config.Workspace.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) { - return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") - } profile, env, err := workspaceEnvironment(config) if err != nil { return nil, nil, err @@ -72,20 +63,13 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace return profile, append(env, credentials...), nil } -func workspaceCwd(w *WorkspaceConfig) string { - if w.PublicDirectory != "" { - return w.PublicDirectory - } - return w.Directory -} - // Harness state paths and selected model credentials overlay the user environment. func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { fail := func() (*workspaceProfile, []string, error) { return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } w := config.Workspace - if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { + if w == nil { return fail() } for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { @@ -94,27 +78,20 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return fail() } } - for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { + for _, path := range []string{w.Directory, config.StateDir, w.HomeDir, w.ScratchDir} { if !canonicalWorkspaceDir(path) { return fail() } } - if w.PublicDirectory != "" { - actual, err := os.Stat(w.Directory) - alias, aliasErr := os.Stat(w.PublicDirectory) - if err != nil || aliasErr != nil || !os.SameFile(actual, alias) { - return fail() - } - } - profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} + profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}} env := []string{} for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") - if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "ANTHROPIC_CUSTOM_HEADERS" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { + if !slices.Contains([]string{"ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_MAX_OUTPUT_TOKENS", "CLAUDE_CONFIG_DIR", "HOME", "TMPDIR"}, name) { env = append(env, entry) } } - env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1") + env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir) seen := map[string]bool{} for _, entry := range config.Env { name, _, ok := strings.Cut(entry, "=") @@ -132,7 +109,8 @@ func workspaceEnvName(name string) bool { switch name { case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_MAX_OUTPUT_TOKENS", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": return true default: return false 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 b0143e694..df08c4bb2 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -91,7 +91,6 @@ func TestLiveClaudeWorkspaceFactory(t *testing.T) { out := make(chan proto.Envelope, 64) req := workspaceRequest() req.RunID, req.Input, req.AgentSessionID = uuid.NewString(), proto.TextInput(prompt), resume - req.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} running, err := startSingleTurn(ctx, config, req, out) @@ -155,6 +154,12 @@ func TestLiveClaudeWorkspaceFactory(t *testing.T) { t.Fatal("cancellation outcome differs from terminal Done") } } + // Native history settles when the executor closes after Done; the next step must not race that close. + select { + case <-s.process.Done(): + case <-ctx.Done(): + t.Fatal("native process did not exit after the Turn") + } writeEvidence(name, proof) if proof.Terminals != 1 || (cancelOnEffect && !proof.Cancelled) { t.Fatal("missing actual cancellation or unique completion") diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index ec627a49e..4b77d59ee 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -18,9 +18,8 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T t.Run(mode, func(t *testing.T) { config := preparationFixture(t, mode) req := preparationRequest() - 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)}} + req.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} e, err := NewExecutorFactory(config)(t.Context(), req) if mode == "structured-missing" { if err == nil || !strings.Contains(err.Error(), "workspace structured output") { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index 2dab86f61..c5949c07b 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -84,8 +84,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { case "none": req.DisableExecutionEnvironment = true case "workspace-root": - config.Workspace.NetworkAccess = "enabled" - req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: config.Workspace.ScratchDir} + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: config.Workspace.ScratchDir} case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} case "relative": @@ -139,9 +138,8 @@ func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled", WorkspaceRoot: config.Workspace.Directory} + req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace.Directory} token := "vault-selected-canary" tools := []string{"prove"} req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", AllowedTools: &tools, Required: true, BearerToken: &token}} diff --git a/apps/daemon/internal/agent/codex/boundary_independent_qa_test.go b/apps/daemon/internal/agent/codex/boundary_independent_qa_test.go index 83e4dece1..4600e547b 100644 --- a/apps/daemon/internal/agent/codex/boundary_independent_qa_test.go +++ b/apps/daemon/internal/agent/codex/boundary_independent_qa_test.go @@ -85,7 +85,6 @@ func TestQANativeBoundaryActualLifecycle(t *testing.T) { func TestQANativeFirstTextFiltering(t *testing.T) { var logs bytes.Buffer s := boundaryTestSession(&logs) - s.observeMessages = true for _, raw := range []string{ `{"threadId":"root","turnId":"stale","itemId":"i","delta":"SECRET_CANARY"}`, `{"threadId":"root","turnId":"55555555-5555-4555-8555-555555555555","itemId":"i","delta":""}`, diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go index 1304b87e5..97859b972 100644 --- a/apps/daemon/internal/agent/codex/contracts.go +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -9,7 +9,6 @@ var ( _ agent.Turn = (*Session)(nil) _ agent.Session = (*Session)(nil) _ agent.DurableSteerer = (*Session)(nil) - _ agent.Steerer = (*Session)(nil) _ agent.FunctionResultSubmitter = (*Session)(nil) _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 6d02affce..ff96549d3 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -7,44 +7,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" ) -// Declaration owns Codex discovery, configuration and execution factories. -var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{ - Kind: "codex", - Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilitySupported, - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - RetainedNativeHistory: proto.CapabilityUnsupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilitySupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilitySupported, - ExecutionControls: proto.CapabilityFromBool(SupportsTextVerbosity), - TextVerbosity: proto.CapabilityFromBool(SupportsTextVerbosity), - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilitySupported, - FunctionResultImages: proto.CapabilitySupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, - MCPHTTPTools: proto.CapabilitySupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilitySupported, - }, -}, Configuration: configuration.Configuration(), Discover: discover} +// Declaration binds the shared Codex declaration to discovery and execution factories. +var Declaration = agent.Declaration{Declaration: declaration.Declaration, Discover: discover} func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { return discoverWithCheck(ctx, options, info, CheckCLIAvailable) @@ -56,15 +23,16 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i version, err := check(ctx, "") if err != nil { fmt.Fprintf(options.Stderr, "oac-daemon: codex unavailable: %v\n Install: %s\n", err, InstallURL) - return runtime + return agent.Unavailable(info) } runtime.Info.Available, runtime.Info.Version = true, version caps := &runtime.Info.Capabilities - caps.NativeSessionRecovery = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) - caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) - caps.WorkspaceReadPreparation = caps.LocalEnvironment - caps.RetainedNativeHistory = caps.LocalEnvironment - caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) + recovery, local := SupportsNativeSessionRecovery(version), SupportsLocalEnvironment(version) + agent.Narrow(&caps.NativeSessionRecovery, recovery) + agent.Narrow(&caps.MCPHTTPRequired, recovery) + for _, capability := range []*proto.CapabilitySupport{&caps.LocalEnvironment, &caps.WorkspaceReadPreparation, &caps.WorkspaceOutputExport, &caps.RetainedNativeHistory} { + agent.Narrow(capability, local) + } runtime.Executor = NewExecutorFactory(options.StateRoot) 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 87f94299c..0a929a68d 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -4,16 +4,15 @@ import ( "context" "errors" "io" - "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/contracttest" ) 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 }) + 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.Info.Capabilities.WorkspaceReadPreparation.IsSupported() != SupportsLocalEnvironment(version) { t.Fatalf("factories: %+v", runtime) @@ -27,28 +26,16 @@ 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, "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++ { - name := value.Type().Field(i).Name - want := proto.CapabilityUnsupported - if expected[name] { - want = proto.CapabilitySupported - } - if got := value.Field(i).Interface(); got != want { - t.Errorf("%s = %v, want %v", name, got, want) - } - } - if err := Declaration.Info.ValidateDeclaration(); err != nil { - t.Fatal(err) +// Discovery narrows the declaration for the installed host and never widens it. +func TestDiscoveryOnlyNarrowsDeclaration(t *testing.T) { + for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3"} { + runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info(), func(context.Context, string) (string, error) { return version, nil }) + contracttest.RequireNarrowed(t, Declaration.Capabilities, runtime.Info.Capabilities) } } 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") }) + 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 { 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 725e5ef58..4eefb7b9c 100644 --- a/apps/daemon/internal/agent/codex/environment.go +++ b/apps/daemon/internal/agent/codex/environment.go @@ -37,7 +37,7 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) return result.Status, nil } -// Native still recognizes the retired transport variables. Reject them before +// Native Codex honors the executor transport variables. Reject them before // setup so the inherited environment cannot select a separate executor. // The explicit none selector remains part of native execution isolation. func validateNativeTransportEnvironment() error { @@ -47,7 +47,7 @@ func validateNativeTransportEnvironment() error { 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 errors.New("codex: 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_transport_test.go similarity index 86% rename from apps/daemon/internal/agent/codex/environment_retired_test.go rename to apps/daemon/internal/agent/codex/environment_transport_test.go index f3e070843..36ed0e9e3 100644 --- a/apps/daemon/internal/agent/codex/environment_retired_test.go +++ b/apps/daemon/internal/agent/codex/environment_transport_test.go @@ -24,7 +24,7 @@ func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { } } -func TestRetiredNativeTransportEnvironmentRejectedBeforeState(t *testing.T) { +func TestNativeTransportEnvironmentRejectedBeforeState(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 _, none := range []bool{false, true} { t.Run(key+"/"+map[bool]string{false: "local", true: "none"}[none], func(t *testing.T) { @@ -33,16 +33,16 @@ func TestRetiredNativeTransportEnvironmentRejectedBeforeState(t *testing.T) { if !none { req.LocalEnvironment = &proto.LocalEnvironment{ID: "local"} } - t.Setenv(key, "retired-private-value") + t.Setenv(key, "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") { + if p != nil || err == nil || !strings.Contains(err.Error(), "executor transport") || strings.Contains(err.Error(), "private-value") { t.Fatal("transport override admitted or disclosed", err) } if len(preparationFrames(t, root)) != 0 { - t.Fatal("retired transport started native process") + t.Fatal("transport override started native process") } if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { - t.Fatal("retired transport created state", err) + t.Fatal("transport override created state", err) } }) } diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index 374e0e1a0..12d072865 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -7,32 +7,59 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestExecutionControlsSelectNativeSettings(t *testing.T) { +func ptr[T any](v T) *T { return &v } + +func TestExecutionControlsMapToNativeSettings(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, search := range []string{"disabled", "cached", "live"} { - for _, verbosity := range []string{"low", "medium", "high"} { - plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "test-model"}, &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity}) + base := [][2]string{{"tools.experimental_request_user_input.enabled", "false"}} + for _, tc := range []struct { + name string + controls *proto.ExecutionControls + want [][2]string + }{ + {"absent controls turn summaries off", nil, nil}, + {"empty controls turn summaries off", &proto.ExecutionControls{}, nil}, + {"verbosity", &proto.ExecutionControls{TextVerbosity: "high"}, [][2]string{{"model_verbosity", `"high"`}}}, + {"search without settings", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}}, + [][2]string{{"web_search", `"live"`}, {"tools.web_search.context_size", `"medium"`}}}, + {"search settings", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "cached", ContextSize: "high", AllowedDomains: []string{"a.example", "b.example"}, + Location: &proto.SearchLocation{City: ptr("Paris"), Timezone: ptr("Europe/Paris")}}}, + [][2]string{{"web_search", `"cached"`}, {"tools.web_search.context_size", `"high"`}, {"tools.web_search.allowed_domains", `["a.example","b.example"]`}, + {"tools.web_search.location.city", `"Paris"`}, {"tools.web_search.location.timezone", `"Europe/Paris"`}}}, + {"empty domain list stays distinct from null", &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "low", AllowedDomains: []string{}}}, + [][2]string{{"web_search", `"disabled"`}, {"tools.web_search.context_size", `"low"`}, {"tools.web_search.allowed_domains", `[]`}}}, + {"reasoning and tier", &proto.ExecutionControls{ReasoningEffort: "max", ReasoningSummary: "concise", ServiceTier: "priority"}, + [][2]string{{"model_reasoning_effort", `"max"`}, {"model_reasoning_summary", `"concise"`}, {"service_tier", `"priority"`}}}, + } { + t.Run(tc.name, func(t *testing.T) { + plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "test-model"}, tc.controls) if err != nil { t.Fatal(err) } plan.Cleanup() - want := [][2]string{{"tools.experimental_request_user_input.enabled", "false"}, {"web_search", `"` + search + `"`}, {"model_verbosity", `"` + verbosity + `"`}} - if !reflect.DeepEqual(plan.ExtraConfig, want) { + want := append([][2]string{}, base...) + if tc.controls == nil || tc.controls.ReasoningSummary == "" { + want = append(want, [2]string{"model_reasoning_summary", `"none"`}) + } + if want = append(want, tc.want...); !reflect.DeepEqual(plan.ExtraConfig, want) { t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) } - } + if plan.ReasoningSummary != (tc.controls != nil && tc.controls.ReasoningSummary != "") { + t.Fatalf("reasoning summary gate = %v", plan.ReasoningSummary) + } + }) } } -func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { +func TestExecutionControlsRejectUndeclaredValues(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) for _, controls := range []proto.ExecutionControls{ - {}, {WebSearch: "disabled"}, {TextVerbosity: "medium"}, - {WebSearch: "invalid", TextVerbosity: "medium"}, {WebSearch: "disabled", TextVerbosity: "invalid"}, + {WebSearch: &proto.WebSearchControl{Mode: "indexed", ContextSize: "medium"}}, + {TextVerbosity: "invalid"}, {ReasoningEffort: "ultra"}, {ReasoningSummary: "none"}, {ServiceTier: "scale"}, } { if plan, err := BuildSessionPlan(testStateRoot(t), "state", nil, &controls); err == nil { plan.Cleanup() - t.Fatal("invalid controls accepted", controls) + t.Fatal("undeclared controls accepted", controls) } } } diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 2cdb0fe49..0b2ca1a6a 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -70,10 +70,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{boundary: newNativeBoundary(ctx, entered), executor: e, nativeHome: base.nativeHome, - functions: functions, observeMessages: base.observeMessages, observeSubagentIdentities: base.observeSubagentIdentities, + functions: functions, 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, runID: runID, out: out} + bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, reasoningSummary: base.reasoningSummary, runID: runID, out: out} if previous != nil { s.threadID = previous.currentThreadID() s.retiredTurns = make(map[string]bool, len(previous.retiredTurns)+1) diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go index faee82904..2f6150630 100644 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -14,9 +14,12 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// This opt-in test uses the real pinned harness and an explicitly configured -// Responses provider. Credentials are read from a private file, never arguments. -func TestExecutorNativeReuse(t *testing.T) { +// nativeAcceptanceConfig returns the pinned binary and the explicitly +// configured Responses provider of an opt-in native test, and gives the test an +// isolated Runtime home. Credentials are read from a private file, never +// arguments. +func nativeAcceptanceConfig(t *testing.T) (binary string, options map[string]any) { + t.Helper() if os.Getenv("OAC_TEST_CODEX_EXECUTOR_NATIVE") != "1" { t.Skip("requires an authorized native model acceptance environment") } @@ -45,13 +48,18 @@ func TestExecutorNativeReuse(t *testing.T) { for _, name := 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(name, "") } + return binary, map[string]any{"model": model, "model_provider": map[string]any{"base_url": endpoint, "protocol": "responses", "api_key": strings.TrimSpace(string(key))}} +} + +func TestExecutorNativeReuse(t *testing.T) { + binary, options := nativeAcceptanceConfig(t) cfg := defaultSessionConfig(testStateRoot(t)) cfg.codexBinary = binary cfg.logger = slog.New(slog.DiscardHandler) req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "executor-native", - 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))}}, + DisableExecutionEnvironment: true, DisableSubagents: true, + AgentOptions: options, FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, } ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) diff --git a/apps/daemon/internal/agent/codex/executor_turn.go b/apps/daemon/internal/agent/codex/executor_turn.go index 6fa3cebb4..5752cf667 100644 --- a/apps/daemon/internal/agent/codex/executor_turn.go +++ b/apps/daemon/internal/agent/codex/executor_turn.go @@ -44,7 +44,7 @@ func (s *Session) settleExecutorTurn(startErr error) { s.operationsClosed = true s.operationMu.Unlock() s.stopSteering() - s.stopFunctionCalls() + s.stopFunctionCalls(false) if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { s.cancelFn() s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) @@ -118,7 +118,7 @@ func (s *Session) cancelExecutorTurn(ctx context.Context) error { s.cancelReady = make(chan struct{}) go func() { defer close(s.cancelReady) - s.stopFunctionCalls() + s.stopFunctionCalls(false) turnID, active := s.stopSteering() if !s.terminal.Load() && active { if turnID == "" { @@ -149,19 +149,8 @@ func (s *Session) cancelExecutorTurn(ctx context.Context) error { var _ agent.Turn = (*Session)(nil) -// 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 { - var scope struct { - ThreadID string `json:"threadId"` - 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) { - return nil, errors.New("codex: request does not belong to the active turn") - } - } if !s.beginOperation() { return nil, errors.New("codex: turn has settled") } diff --git a/apps/daemon/internal/agent/codex/function_receipt_test.go b/apps/daemon/internal/agent/codex/function_receipt_test.go index fd138a009..63ce1f357 100644 --- a/apps/daemon/internal/agent/codex/function_receipt_test.go +++ b/apps/daemon/internal/agent/codex/function_receipt_test.go @@ -137,7 +137,7 @@ func TestFunctionReceiptResultIsFrozenAndSubmittedOnce(t *testing.T) { if err := s.SubmitFunctionResult(t.Context(), result); err == nil { t.Fatal("settled result admitted again") } - s.stopFunctionCalls() + s.stopFunctionCalls(false) } func TestFunctionReceiptShutdownDoesNotConfirmApplication(t *testing.T) { @@ -151,7 +151,7 @@ func TestFunctionReceiptShutdownDoesNotConfirmApplication(t *testing.T) { case "run cancellation": s.cancelFn() case "terminal": - s.stopFunctionCalls() + s.stopFunctionCalls(false) } select { case err := <-done: @@ -161,7 +161,7 @@ func TestFunctionReceiptShutdownDoesNotConfirmApplication(t *testing.T) { case <-time.After(time.Second): t.Fatal("shutdown did not settle receipt") } - s.stopFunctionCalls() + s.stopFunctionCalls(false) }) } } @@ -199,7 +199,7 @@ func TestFunctionReceiptDeadlineEndsUncertainExecution(t *testing.T) { if s.cancelCtx.Err() == nil { t.Fatal("uncertain native execution remains active") } - s.stopFunctionCalls() + s.stopFunctionCalls(false) } // The receipt and deadline are both ready when the waiter resumes. Either select @@ -211,7 +211,7 @@ func TestFunctionReceiptWinsSimultaneousDeadline(t *testing.T) { s.setThreadID("thread") s.startSteering("thread", "turn") s.functions, _ = prepareFunctionTools(nil) - pending := &pendingFunction{turnID: "turn", name: "lookup", reply: &functionReply{Success: true, ContentItems: []functionContent{}}, receipt: make(chan error, 1)} + pending := &pendingFunction{thread: "thread", turnID: "turn", name: "lookup", reply: &functionReply{Success: true, ContentItems: []functionContent{}}, receipt: make(chan error, 1)} s.functions.pending["call"] = pending s.confirmFunctionResult(nativeFunctionReceipt("thread", "turn", "call", "lookup", "true", "completed", "[]")) ctx, cancel := context.WithCancel(t.Context()) @@ -254,11 +254,11 @@ func TestFunctionReceiptSurvivesObservationBackpressure(t *testing.T) { cancel() case "cancel": s.cancelled.Store(true) - s.stopFunctionCalls() + s.stopFunctionCalls(false) s.cancelFn() case "terminal": s.terminal.Store(true) - s.stopFunctionCalls() + s.stopFunctionCalls(false) } select { case err := <-receipt: diff --git a/apps/daemon/internal/agent/codex/function_receipts.go b/apps/daemon/internal/agent/codex/function_receipts.go index ba6727b16..f9264de9f 100644 --- a/apps/daemon/internal/agent/codex/function_receipts.go +++ b/apps/daemon/internal/agent/codex/function_receipts.go @@ -18,8 +18,11 @@ type functionReply struct { } type pendingFunction struct { - rpcID any - turnID string + rpcID any + thread string + turnID string + // held is the Subagent call that waits for its child Turn fact. + held *proto.Envelope name string reply *functionReply receipt chan error @@ -51,7 +54,7 @@ func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.Functio } s.functions.mu.Lock() pending := s.functions.pending[result.CallID] - if pending == nil || pending.reply != nil || s.functions.closed || s.cancelled.Load() || s.terminal.Load() { + if pending == nil || pending.reply != nil || s.functions.closed || s.cancelled.Load() || (pending.thread == s.currentThreadID() && s.terminal.Load()) { s.functions.mu.Unlock() return agent.ErrUnknownFunctionCall } @@ -129,13 +132,13 @@ func (s *Session) confirmFunctionResult(raw json.RawMessage) { TurnID string `json:"turnId"` Item toolObservationSource `json:"item"` } - if json.Unmarshal(raw, &event) != nil || event.Item.Type != "dynamicToolCall" || !s.isRootTurn(event.ThreadID, event.TurnID) { + if json.Unmarshal(raw, &event) != nil || event.Item.Type != "dynamicToolCall" { return } s.functions.mu.Lock() defer s.functions.mu.Unlock() pending := s.functions.pending[event.Item.ID] - if pending == nil || pending.reply == nil || s.functions.closed || s.cancelled.Load() { + if pending == nil || pending.thread != event.ThreadID || pending.turnID != event.TurnID || pending.reply == nil || s.functions.closed || s.cancelled.Load() { return } item := event.Item @@ -144,7 +147,7 @@ func (s *Session) confirmFunctionResult(raw json.RawMessage) { status = "failed" } var err error - if pending.turnID != event.TurnID || pending.name != item.Tool || item.Namespace != "" || item.Status != status || item.Success == nil || *item.Success != pending.reply.Success || item.ContentItems == nil || !reflect.DeepEqual(*item.ContentItems, pending.reply.ContentItems) { + if pending.name != item.Tool || item.Namespace != "" || item.Status != status || item.Success == nil || *item.Success != pending.reply.Success || item.ContentItems == nil || !reflect.DeepEqual(*item.ContentItems, pending.reply.ContentItems) { err = errors.New("codex: native function receipt does not match submitted result") } delete(s.functions.pending, item.ID) diff --git a/apps/daemon/internal/agent/codex/functions.go b/apps/daemon/internal/agent/codex/functions.go index e2a359d4b..925f50e3a 100644 --- a/apps/daemon/internal/agent/codex/functions.go +++ b/apps/daemon/internal/agent/codex/functions.go @@ -1,6 +1,7 @@ package codex import ( + "context" "encoding/json" "errors" "strings" @@ -28,9 +29,6 @@ type functionCalls struct { func prepareFunctionTools(tools []proto.FunctionTool) (*functionCalls, error) { state := &functionCalls{names: map[string]bool{}, pending: map[string]*pendingFunction{}} - if len(tools) > 64 { - return nil, errors.New("at most 64 function tools are supported") - } for _, tool := range tools { var schema map[string]any if strings.TrimSpace(tool.Name) == "" || state.names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { @@ -54,21 +52,38 @@ func (s *Session) handleFunctionCall(raw json.RawMessage, rpcID any) (any, error if err := json.Unmarshal(raw, &call); err != nil { return nil, err } - if s.functions == nil || !s.functions.names[call.Tool] || call.Namespace != nil || call.CallID == "" || !s.isRootTurn(call.ThreadID, call.TurnID) || !json.Valid(call.Arguments) { + root := s.isRootTurn(call.ThreadID, call.TurnID) + child := !root && s.subagents != nil && call.TurnID != "" && call.ThreadID != "" && !s.isRootThread(call.ThreadID) + if s.functions == nil || !s.functions.names[call.Tool] || call.Namespace != nil || call.CallID == "" || !(root || child) || !json.Valid(call.Arguments) { return nil, errors.New("unexpected function call") } + payload := proto.FunctionCallPayload{CallID: call.CallID, Name: call.Tool, Arguments: call.Arguments} + if child { + payload.NativeID, payload.TurnID = call.ThreadID, call.TurnID + } + env, err := proto.NewEnvelope(proto.TypeFunctionCall, s.runID, payload) + if err != nil { + return nil, err + } s.functions.mu.Lock() - if s.functions.closed || s.cancelled.Load() || s.terminal.Load() || s.functions.pending[call.CallID] != nil || len(s.functions.pending) >= 64 { + if s.functions.closed || s.cancelled.Load() || (root && s.terminal.Load()) || s.functions.pending[call.CallID] != nil || len(s.functions.pending) >= 64 { s.functions.mu.Unlock() return nil, errors.New("function call cannot be admitted") } - s.functions.pending[call.CallID] = &pendingFunction{rpcID: rpcID, turnID: call.TurnID, name: call.Tool, receipt: make(chan error, 1)} + pending := &pendingFunction{rpcID: rpcID, thread: call.ThreadID, turnID: call.TurnID, name: call.Tool, receipt: make(chan error, 1)} + if child { + pending.held = &env + } + s.functions.pending[call.CallID] = pending s.functions.mu.Unlock() - env, err := proto.NewEnvelope(proto.TypeFunctionCall, s.runID, proto.FunctionCallPayload{CallID: call.CallID, Name: call.Tool, Arguments: call.Arguments}) - if err == nil { - err = s.sendFunctionCall(env) + if child { + select { + case s.subagents.wake <- struct{}{}: + default: + } + return DeferReply, nil } - if err != nil { + if err = s.sendFunctionCall(env); err != nil { s.functions.mu.Lock() delete(s.functions.pending, call.CallID) s.functions.mu.Unlock() @@ -101,14 +116,72 @@ type functionContent struct { ImageURL *string `json:"imageUrl,omitempty"` } -func (s *Session) stopFunctionCalls() { +// stopFunctionCalls ends every pending call. With rootOnly it ends the root's +// calls and keeps accepting those of Subagents, which outlive the root Turn. +func (s *Session) stopFunctionCalls(rootOnly bool) { if s.functions != nil { s.functions.mu.Lock() - s.functions.closed = true - for _, pending := range s.functions.pending { - pending.receipt <- agent.ErrUnknownFunctionCall + s.functions.closed = s.functions.closed || !rootOnly + for id, pending := range s.functions.pending { + if !rootOnly || pending.thread == s.currentThreadID() { + pending.receipt <- agent.ErrUnknownFunctionCall + delete(s.functions.pending, id) + } } - clear(s.functions.pending) s.functions.mu.Unlock() } } + +// withdrawChildCalls removes the pending calls of a child Turn that ended and +// reports them to Core as withdrawn. Native drops their requests without a +// reply, so no result can be applied. A call still held was never reported. +func (s *Session) withdrawChildCalls(ctx context.Context, thread, turn string) error { + if s.functions == nil { + return nil + } + var withdrawn []proto.Envelope + s.functions.mu.Lock() + for id, pending := range s.functions.pending { + if pending.thread != thread || pending.turnID != turn { + continue + } + delete(s.functions.pending, id) + pending.receipt <- agent.ErrUnknownFunctionCall + if pending.held != nil { + continue + } + env, err := proto.NewEnvelope(proto.TypeFunctionCallWithdrawn, s.runID, proto.FunctionCallWithdrawnPayload{CallID: id, NativeID: thread, TurnID: turn}) + if err != nil { + s.functions.mu.Unlock() + return err + } + withdrawn = append(withdrawn, env) + } + s.functions.mu.Unlock() + for _, env := range withdrawn { + if !s.sendWithin(ctx, env) { + return errors.New("codex: function call withdrawal delivery unavailable") + } + } + return nil +} + +// sendHeldFunctionCalls sends the Subagent calls whose child Turn Core already +// has, because Core resolves a child's call through its identity. +func (s *Session) sendHeldFunctionCalls(ctx context.Context) { + if s.functions == nil { + return + } + var ready []proto.Envelope + s.functions.mu.Lock() + for _, pending := range s.functions.pending { + if _, sent := s.subagents.sent[proto.TypeSubagentTurn+":"+pending.thread+":"+pending.turnID]; sent && pending.held != nil { + ready = append(ready, *pending.held) + pending.held = nil + } + } + s.functions.mu.Unlock() + for _, env := range ready { + s.sendWithin(ctx, env) + } +} diff --git a/apps/daemon/internal/agent/codex/functions_test.go b/apps/daemon/internal/agent/codex/functions_test.go index a2fd9c752..6019a1680 100644 --- a/apps/daemon/internal/agent/codex/functions_test.go +++ b/apps/daemon/internal/agent/codex/functions_test.go @@ -109,7 +109,7 @@ func TestFunctionCallRejectsUnregisteredAndClosedRuns(t *testing.T) { t.Fatal("unexpected call accepted", params) } } - s.stopFunctionCalls() + s.stopFunctionCalls(false) if _, err := s.handleFunctionCall(json.RawMessage(`{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`), "rpc"); err == nil { t.Fatal("closed run accepted call") } @@ -118,6 +118,56 @@ func TestFunctionCallRejectsUnregisteredAndClosedRuns(t *testing.T) { } } +func TestSubagentFunctionCallWaitsForChildTurnAndOutlivesRoot(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newFunctionTestSession(tc.JSONRPCClient) + s.subagents = &subagentObservations{wake: make(chan struct{}, 1), sent: map[string]string{}} + s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}) + s.setThreadID("root") + s.startSteering("root", "turn") + if err := SendServerRequest(srv, "rpc-call", "item/tool/call", map[string]any{"threadId": "child", "turnId": "child-turn", "callId": "call", "tool": "lookup", "arguments": map[string]string{}}); err != nil { + t.Fatal(err) + } + select { + case <-s.subagents.wake: + case <-time.After(time.Second): + t.Fatal("child call did not wake the observer") + } + // The root Turn ends while the child still waits for its answer. + s.terminal.Store(true) + s.stopFunctionCalls(true) + s.sendHeldFunctionCalls(t.Context()) + select { + case env := <-out: + t.Fatal("call sent before its child Turn fact", env) + default: + } + s.subagents.sent[proto.TypeSubagentTurn+":child:child-turn"] = "{}" + s.sendHeldFunctionCalls(t.Context()) + var call proto.FunctionCallPayload + if env := <-out; env.DecodePayload(&call) != nil || call.CallID != "call" || call.NativeID != "child" || call.TurnID != "child-turn" { + t.Fatal(env) + } + text := "answer" + finished := make(chan error, 1) + go func() { + finished <- s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}) + }() + var reply struct { + Result map[string]any `json:"result"` + } + if err := json.NewDecoder(srv.FromClient).Decode(&reply); err != nil { + t.Fatal(err) + } + reply.Result["type"], reply.Result["id"], reply.Result["tool"], reply.Result["status"] = "dynamicToolCall", "call", "lookup", "completed" + native, _ := json.Marshal(map[string]any{"threadId": "child", "turnId": "child-turn", "item": reply.Result}) + s.onItemCompleted(native) + if err := <-finished; err != nil { + t.Fatal(err) + } +} + func TestFunctionToolDefinitionsRejectAmbiguousInput(t *testing.T) { for _, tools := range [][]proto.FunctionTool{ {{Name: "", Parameters: json.RawMessage(`{}`)}}, @@ -130,3 +180,34 @@ func TestFunctionToolDefinitionsRejectAmbiguousInput(t *testing.T) { } } } + +func TestEndedChildTurnWithdrawsItsPendingCall(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newFunctionTestSession(tc.JSONRPCClient) + s.subagents = &subagentObservations{wake: make(chan struct{}, 1), sent: map[string]string{proto.TypeSubagentTurn + ":child:child-turn": "{}"}} + s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}) + s.setThreadID("root") + s.startSteering("root", "turn") + if err := SendServerRequest(srv, "rpc-call", "item/tool/call", map[string]any{"threadId": "child", "turnId": "child-turn", "callId": "call", "tool": "lookup", "arguments": map[string]string{}}); err != nil { + t.Fatal(err) + } + <-s.subagents.wake + s.sendHeldFunctionCalls(t.Context()) + <-out + if err := s.withdrawChildCalls(t.Context(), "child", "child-turn"); err != nil { + t.Fatal(err) + } + var withdrawn proto.FunctionCallWithdrawnPayload + if env := <-out; env.Type != proto.TypeFunctionCallWithdrawn || env.DecodePayload(&withdrawn) != nil || withdrawn != (proto.FunctionCallWithdrawnPayload{CallID: "call", NativeID: "child", TurnID: "child-turn"}) { + t.Fatal(env) + } + if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Success: true, Content: []proto.InputContent{}}); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatal(err) + } + select { + case <-s.rpc.Done(): + t.Fatal("a late result closed the native process") + default: + } +} diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go index 7ca086aa9..7b9150ccc 100644 --- a/apps/daemon/internal/agent/codex/harness_config_test.go +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -8,24 +8,32 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "slices" + "strings" "testing" ) -func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { +func TestReasoningEffortAppliedWithoutChangingProvider(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) plan, err := BuildSessionPlan(testStateRoot(t), "native-config", map[string]any{ - "model": "fixture", "harness_config": map[string]any{"model_reasoning_effort": "high"}, + "model": "fixture", "model_provider": map[string]any{"base_url": "https://provider.invalid/v1", "protocol": "responses", "api_key": "test-key"}, - }, nil) + }, &proto.ExecutionControls{ReasoningEffort: "max"}) if err != nil { t.Fatal(err) } defer plan.Cleanup() - if plan.Model != "fixture" || plan.ModelProvider != oacProviderSlug || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"high"`}) { + if plan.Model != "fixture" || plan.ModelProvider != oacProviderSlug || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"max"`}) { t.Fatalf("native configuration not applied: %+v", plan.ExtraConfig) } } +func TestReasoningEffortHasNoHarnessConfigHome(t *testing.T) { + _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_reasoning_effort": "high"}}, nil) + if err != harnessconfig.ErrHarnessConfig { + t.Fatalf("duplicate effort home accepted: %v", err) + } +} + func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { _, err := BuildSessionPlan(testStateRoot(t), "", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}, nil) if err != harnessconfig.ErrHarnessConfig { @@ -33,14 +41,14 @@ func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { } } -func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { +func TestNativeTurnsCarryNoCollaborationMode(t *testing.T) { for _, resumeID := range []string{"", "fixture-native-thread"} { t.Run("resume="+resumeID, func(t *testing.T) { req, cfg, root := preparationFixture(t) t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") req.AgentSessionID = resumeID - native := map[string]any{"model_reasoning_effort": "high"} - req.AgentOptions["harness_config"] = native + controls := &proto.ExecutionControls{TextVerbosity: "medium", ReasoningEffort: "high"} + req.ExecutionControls = controls ownerCtx, cancelOwner := context.WithCancel(context.Background()) t.Cleanup(cancelOwner) e, err := newExecutor(ownerCtx, req, cfg) @@ -54,7 +62,7 @@ func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { t.Error(err) } }) - native["model_reasoning_effort"] = "low" + controls.ReasoningEffort = "low" for _, id := range []string{"one", "two"} { out := make(chan proto.Envelope, 20) turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) @@ -71,13 +79,8 @@ func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { if frame.Method != "turn/start" { continue } - var wire struct { - CollaborationMode struct { - Settings map[string]any `json:"settings"` - } `json:"collaborationMode"` - } - if json.Unmarshal(frame.Params, &wire) != nil || wire.CollaborationMode.Settings["reasoning_effort"] != "high" { - t.Fatal("Turn lost the frozen native reasoning effort", string(frame.Params)) + if strings.Contains(string(frame.Params), "ollaborationMode") { + t.Fatal("turn/start must not carry a collaboration mode", string(frame.Params)) } } method := "thread/start" @@ -90,3 +93,52 @@ func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { }) } } + +func TestOutputSchemaReachesEveryNativeTurn(t *testing.T) { + for _, resumeID := range []string{"", "fixture-native-thread"} { + t.Run("resume="+resumeID, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") + req.AgentSessionID = resumeID + schema := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}`) + req.ExecutionControls = &proto.ExecutionControls{OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}} + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + t.Cleanup(cancelOwner) + e, err := newExecutor(ownerCtx, req, cfg) + if err != nil { + t.Fatal(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) + } + }) + for _, id := range []string{"one", "two"} { + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + awaitExecutorTurn(t, turn, out) + } + turns := 0 + for _, frame := range preparationFrames(t, root) { + if frame.Method != "turn/start" { + continue + } + turns++ + var wire struct { + OutputSchema json.RawMessage `json:"outputSchema"` + } + if json.Unmarshal(frame.Params, &wire) != nil || string(wire.OutputSchema) != string(schema) { + t.Fatal("Turn lost the frozen output schema", string(frame.Params)) + } + } + if turns != 2 { + t.Fatal("expected two Turns", turns) + } + }) + } +} diff --git a/apps/daemon/internal/agent/codex/hosted_skills.go b/apps/daemon/internal/agent/codex/hosted_skills.go deleted file mode 100644 index 7b20452cb..000000000 --- a/apps/daemon/internal/agent/codex/hosted_skills.go +++ /dev/null @@ -1,41 +0,0 @@ -package codex - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" -) - -func verifyHostedSkills(skills []agentcapabilities.InstalledSkill) error { - for _, skill := range skills { - if err := verifyHostedSkillLayout(localworkspace.SkillPath(skill)); err != nil { - return err - } - } - return nil -} - -func verifyHostedSkillLayout(root string) error { - // Native dependency declarations may start MCP installation outside the - // workspace tool sandbox. They are not qualified by an inert Skill upload. - if _, err := os.Lstat(filepath.Join(root, "agents", "openai.yaml")); err == nil { - return fmt.Errorf("codex: native Skill configuration is unsupported") - } else if !os.IsNotExist(err) { - return err - } - // Extra roots are scanned recursively. One public Skill must not silently - // introduce additional native Skills with their own activation metadata. - return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Name() == "SKILL.md" && path != filepath.Join(root, "SKILL.md") { - return fmt.Errorf("codex: nested native Skills are unsupported") - } - return nil - }) -} diff --git a/apps/daemon/internal/agent/codex/hosted_skills_test.go b/apps/daemon/internal/agent/codex/hosted_skills_test.go deleted file mode 100644 index 0c7455687..000000000 --- a/apps/daemon/internal/agent/codex/hosted_skills_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - "testing" -) - -func TestHostedSkillDoesNotActivateAdditionalNativeResources(t *testing.T) { - for _, tc := range []struct { - name string - files []string - rejected bool - }{ - {"ordinary supporting files", []string{"SKILL.md", "scripts/check.py", "references/guide.md"}, false}, - {"native dependency configuration", []string{"SKILL.md", "agents/openai.yaml"}, true}, - {"nested discovery", []string{"SKILL.md", "references/other/SKILL.md"}, true}, - {"nested dependencies", []string{"SKILL.md", "references/other/SKILL.md", "references/other/agents/openai.yaml"}, true}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - for _, name := range tc.files { - path := filepath.Join(root, name) - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("fixture"), 0400); err != nil { - t.Fatal(err) - } - } - if err := verifyHostedSkillLayout(root); (err != nil) != tc.rejected { - t.Fatalf("rejected=%v err=%v", tc.rejected, err) - } - }) - } -} diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index 653726a43..27133314c 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -16,11 +16,12 @@ type mcpServerConfig struct { URL string Command string Args []string + Cwd string + EnvVars []string EnabledTools *[]string Required bool BearerTokenEnvVar string - EnvHTTPHeaders map[string]string - ApproveTools bool + HTTPHeaders map[string]string } // writeCodexMCPConfig writes a `[mcp_servers.]` TOML table per @@ -48,9 +49,6 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString("[mcp_servers.") b.WriteString(tomlQuoteString(name)) b.WriteString("]\n") - if srv.ApproveTools { - b.WriteString("default_tools_approval_mode = \"approve\"\n") - } if srv.Required { b.WriteString("required = true\n") } @@ -63,32 +61,20 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString(tomlQuoteString(srv.BearerTokenEnvVar)) b.WriteByte('\n') } - if srv.EnabledTools != nil { - b.WriteString("enabled_tools = [") - for i, name := range *srv.EnabledTools { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(tomlQuoteString(name)) - } - b.WriteString("]\n") - } - writeMCPHeaderMap(&b, "env_http_headers", srv.EnvHTTPHeaders) + writeMCPHeaderMap(&b, "http_headers", srv.HTTPHeaders) + } else { + b.WriteString(`command = `) + b.WriteString(tomlQuoteString(srv.Command)) b.WriteByte('\n') - continue - } - b.WriteString(`command = `) - b.WriteString(tomlQuoteString(srv.Command)) - b.WriteByte('\n') - if len(srv.Args) > 0 { - b.WriteString("args = [") - for i, a := range srv.Args { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(tomlQuoteString(a)) + writeMCPList(&b, "args", srv.Args) + if srv.Cwd != "" { + b.WriteString("cwd = " + tomlQuoteString(srv.Cwd) + "\n") } - b.WriteString("]\n") + writeMCPList(&b, "env_vars", srv.EnvVars) + } + if srv.EnabledTools != nil { + b.WriteString("enabled_tools = ") + writeMCPList(&b, "", *srv.EnabledTools) } b.WriteByte('\n') } @@ -97,6 +83,24 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e return appendConfigTOML(path, b.String()) } +// writeMCPList writes a TOML string array, as a "name = " line unless name is empty. +func writeMCPList(b *strings.Builder, name string, values []string) { + if name != "" { + if len(values) == 0 { + return + } + b.WriteString(name + " = ") + } + b.WriteByte('[') + for i, value := range values { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(value)) + } + b.WriteString("]\n") +} + func writeMCPHeaderMap(b *strings.Builder, field string, values map[string]string) { if len(values) == 0 { return @@ -118,7 +122,8 @@ func writeMCPHeaderMap(b *strings.Builder, field string, values map[string]strin // tomlQuoteString returns a TOML basic-string literal (double-quoted) // with the documented escape set applied — \" \\ \n \r \t plus -// \uXXXX for control chars. Matches the TOML 1.0 spec for basic strings. +// \uXXXX for the other control characters, DEL included. Matches the TOML 1.0 +// spec for basic strings. func tomlQuoteString(s string) string { var b strings.Builder b.WriteByte('"') @@ -135,7 +140,7 @@ func tomlQuoteString(s string) string { case '\t': b.WriteString(`\t`) default: - if r < 0x20 { + if r < 0x20 || r == 0x7f { fmt.Fprintf(&b, `\u%04X`, r) } else { b.WriteRune(r) diff --git a/apps/daemon/internal/agent/codex/mcp_environment_test.go b/apps/daemon/internal/agent/codex/mcp_environment_test.go index 0013b3a76..147a2e7c3 100644 --- a/apps/daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/daemon/internal/agent/codex/mcp_environment_test.go @@ -13,30 +13,29 @@ import ( func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { token := "user-token" - local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ + local := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{ {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, }} servers, env, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}) - if err != nil || len(servers) != 2 || len(env) != 2 { + if err != nil || len(servers) != 2 || len(env) != 1 { t.Fatal("environment declarations were not projected", err) } executable, _ := os.Executable() - if servers["local"].Command != executable || !servers["local"].ApproveTools || + if servers["local"].Command != executable || !slices.Equal(servers["local"].Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "local"}) { t.Fatal("native stdio bypasses the packaged launcher") } remote := servers["remote"] - if remote.BearerTokenEnvVar == "" || remote.EnvHTTPHeaders["X-Key"] == "" || - !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) || - !slices.Contains(env, remote.EnvHTTPHeaders["X-Key"]+"=literal-${DO_NOT_EXPAND}") { + if remote.BearerTokenEnvVar == "" || remote.HTTPHeaders["X-Key"] != "literal-${DO_NOT_EXPAND}" || + !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) { t.Fatal("private header values changed") } fixture := map[string]any{"config": map[string]any{ - "mcp_oauth_credentials_store": "file", "features": map[string]bool{"plugins": false, "apps": false}, + "mcp_oauth_credentials_store": "file", "mcp_servers": map[string]any{ - "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, - "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve", "bearer_token_env_var": remote.BearerTokenEnvVar, "env_http_headers": remote.EnvHTTPHeaders}, + "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil}, + "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "bearer_token_env_var": remote.BearerTokenEnvVar, "http_headers": remote.HTTPHeaders}, }, }} raw, _ := json.Marshal(fixture) @@ -49,15 +48,17 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. } } -func TestEnvironmentMCPRejectsUnqualifiedNetworkAndCredentialChanges(t *testing.T) { - for _, access := range []string{"", "restricted", "disabled"} { - local := &proto.LocalEnvironment{NetworkAccess: access, MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "local", Type: "stdio"}}}} - if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}); err == nil { - t.Errorf("unqualified MCP network accepted: %s", access) - } +func TestSessionMCPHeadersReachNativeConfig(t *testing.T) { + req := proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp", Headers: map[string]string{"X-Tenant": "a"}}}} + servers, env, err := runtimeMCPServers(req) + if err != nil || servers["remote"].HTTPHeaders["X-Tenant"] != "a" || len(env) != 0 { + t.Fatal("Session headers were not projected", err) } +} + +func TestEnvironmentMCPRejectsUnqualifiedCredentialChanges(t *testing.T) { token := "user-token" - local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} + local := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}); err == nil { t.Fatal("plaintext bearer accepted") } diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index d5a935f7d..11358de3a 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -5,15 +5,13 @@ import ( "errors" "os" "path/filepath" - "slices" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// One projection consumes both public and installed Runtime bindings. A non-nil -// empty public declaration still owns the complete native MCP configuration. +// One projection consumes both public and installed Runtime bindings. func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConfig, []string, error) { bindings, err := agent.ResolveMCPBindings(req) if err != nil { @@ -28,22 +26,17 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf if binding.ServerLabel == "codex_apps" { return nil, nil, errors.New("codex: reserved MCP server label") } - server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, ApproveTools: binding.ConnectionOrigin == "environment"} + server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, HTTPHeaders: binding.HTTPHeaders} if binding.Stdio != nil { server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) } + if process := binding.Process; process != nil { + server.Command, server.Args, server.Cwd, server.EnvVars = process.Command, process.Args, process.Cwd, process.EnvVars + } if binding.BearerToken != nil { server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() env = append(env, server.BearerTokenEnvVar+"="+*binding.BearerToken) } - if len(binding.HTTPHeaders) > 0 { - server.EnvHTTPHeaders = map[string]string{} - for name, value := range binding.HTTPHeaders { - reference := "OAC_RUNTIME_MCP_HEADER_" + rand.Text() - server.EnvHTTPHeaders[name] = reference - env = append(env, reference+"="+value) - } - } servers[server.Name] = server } return servers, env, nil @@ -62,11 +55,8 @@ func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { if err := writeCodexMCPConfig(codexHome, servers); err != nil { return errors.New("codex: cannot write public MCP configuration") } - for _, feature := range []string{"plugins", "apps"} { - if !slices.Contains(plan.DisableFeatures, feature) { - plan.DisableFeatures = append(plan.DisableFeatures, feature) - } - } + // Native shell tools must not inherit the per-server secret variables. + excludeFromShellEnvironment(plan, "OAC_RUNTIME_MCP_*") plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) plan.mcpServers = servers return nil 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 5a68ce270..c7dcaf0b5 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -68,39 +68,13 @@ func TestMCPHTTPBearerRejectsInvalidTokensWithoutPersistence(t *testing.T) { } } -func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "catalog-probe") - script := "#!/bin/sh\nif env | grep -q '^OAC_RUNTIME_MCP_BEARER_'; then exit 9; fi\nprintf '%s' '{\"models\":[{\"slug\":\"fixture-model\",\"support_verbosity\":true}]}'\n" - if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - 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"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} - cfg := defaultSessionConfig(testStateRoot(t)) - cfg.codexBinary = binary - plan, _, err := prepareSessionPlan(t.Context(), req, cfg) - if err != nil { - t.Fatal("catalog probe inherited bearer or failed", err) - } - defer plan.Cleanup() - if !slices.Contains(plan.Env, plan.mcpServers["tools"].BearerTokenEnvVar+"="+token) { - t.Fatal("app-server did not receive bearer after catalog probe") - } -} - func TestMCPHTTPBearerPreflightMatchesOnlyItsServerReference(t *testing.T) { servers := map[string]mcpServerConfig{ "first": {URL: "https://first.example/mcp", BearerTokenEnvVar: "OAC_RUNTIME_MCP_BEARER_FIRST"}, "second": {URL: "https://second.example/mcp", BearerTokenEnvVar: "OAC_RUNTIME_MCP_BEARER_SECOND"}, "public": {URL: "http://public.example/mcp"}, } - for _, mutation := range []string{"none", "missing", "ambient", "swapped", "extra", "header", "helper"} { + for _, mutation := range []string{"none", "missing", "ambient", "swapped", "extra", "header"} { t.Run(mutation, func(t *testing.T) { response := mcpHTTPConfigResponse(servers) entries := response["config"].(map[string]any)["mcp_servers"].(map[string]any) @@ -116,8 +90,6 @@ func TestMCPHTTPBearerPreflightMatchesOnlyItsServerReference(t *testing.T) { entries["public"].(map[string]any)["bearer_token_env_var"] = servers["first"].BearerTokenEnvVar case "header": first["http_headers"] = map[string]string{"Authorization": "Bearer synthetic-private"} - case "helper": - first["http_headers_helper"] = "operator-helper" } raw, err := json.Marshal(response) if err != nil || matchesMCPConfig(raw, servers) != (mutation == "none") { diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight.go b/apps/daemon/internal/agent/codex/mcp_http_preflight.go index b499038fb..6c257777e 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight.go @@ -8,9 +8,15 @@ import ( "time" ) -// config/read loads the same cwd and CLI layers without MCP discovery. Native -// mcpServerStatus/list instead opens eager discovery connections; do not use it -// to decide whether undeclared servers are safe to contact. +// errMCPBindingChanged reports that the effective native configuration moved a +// declared server's credential binding or the credential store. +var errMCPBindingChanged = errors.New("codex: effective native MCP configuration differs from the public declaration") + +// verifyMCPConfig runs after the workspace trust entry is in the generated +// configuration, so config/read sees the same project layer thread/start and +// thread/resume load. It runs before any native connection that carries a Core +// credential. config/read loads the layers without MCP discovery; native +// mcpServerStatus/list instead opens eager discovery connections. func verifyMCPConfig(ctx context.Context, rpc *JSONRPCClient, plan SessionPlan) error { check, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -20,16 +26,19 @@ func verifyMCPConfig(ctx context.Context, rpc *JSONRPCClient, plan SessionPlan) return errors.New("codex: cannot verify public MCP configuration") } if !matchesMCPConfig(raw, plan.mcpServers) { - return errors.New("codex: effective native MCP configuration differs from the public declaration") + return errMCPBindingChanged } return nil } +// matchesMCPConfig checks only what binds a Core credential to a declared +// server: where the server runs and connects, which variables carry its bearer +// and headers, and the declared tool policy. Other servers and settings follow +// native defaults. func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) bool { var response struct { Config struct { Servers map[string]map[string]any `json:"mcp_servers"` - Features map[string]any `json:"features"` CredentialStore string `json:"mcp_oauth_credentials_store"` } `json:"config"` } @@ -37,103 +46,87 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) return false } config := response.Config - if config.CredentialStore != "file" || config.Features["plugins"] != false || config.Features["apps"] != false || config.Servers == nil || len(config.Servers) != len(declared) { + if config.CredentialStore != "file" || (len(declared) > 0 && config.Servers == nil) { return false } for name, expected := range declared { server, exists := config.Servers[name] - if !exists || server["environment_id"] != "local" || server["enabled"] != true { + if !exists || server["environment_id"] != "local" { return false } if expected.URL != "" { - if server["url"] != expected.URL || !matchesMCPHeaderMap(server, "env_http_headers", expected.EnvHTTPHeaders) { + if server["url"] != expected.URL { return false } - delete(server, "url") } else { - args, err := json.Marshal(expected.Args) - var want any - if err != nil || json.Unmarshal(args, &want) != nil || server["command"] != expected.Command || !reflect.DeepEqual(server["args"], want) { - return false - } - delete(server, "command") - delete(server, "args") - } - delete(server, "environment_id") - delete(server, "enabled") - if expected.ApproveTools { - if server["default_tools_approval_mode"] != "approve" { - return false - } - delete(server, "default_tools_approval_mode") - } - if expected.Required { - if server["required"] != true { + if server["command"] != expected.Command || !matchesMCPList(server["args"], expected.Args) || + server["cwd"] != nilIfEmpty(expected.Cwd) || !matchesMCPList(server["env_vars"], expected.EnvVars) { return false } - delete(server, "required") } - if expected.BearerTokenEnvVar != "" { - if server["bearer_token_env_var"] != expected.BearerTokenEnvVar { - return false - } - delete(server, "bearer_token_env_var") + if server["bearer_token_env_var"] != nilIfEmpty(expected.BearerTokenEnvVar) || + !matchesMCPHeaderMap(server, "http_headers", expected.HTTPHeaders) || + !matchesMCPHeaderMap(server, "env_http_headers", nil) { + return false } - if expected.EnabledTools != nil { - // Compare sets: native enabled_tools is an allowlist, not an ordered program. - actual, ok := server["enabled_tools"].([]any) - if !ok { - return false - } - want := make(map[string]bool, len(*expected.EnabledTools)) - for _, tool := range *expected.EnabledTools { - want[tool] = true - } - got := make(map[string]bool, len(actual)) - for _, tool := range actual { - name, ok := tool.(string) - if !ok { - return false - } - got[name] = true - } - if !reflect.DeepEqual(want, got) { - return false - } - delete(server, "enabled_tools") + if expected.Required && server["required"] != true { + return false } - // These are the native serializer's inert defaults. Reject all additional - // settings, including headers, credential helpers, tool policies and filters. - for key, value := range server { - switch key { - case "tool_timeout_sec": - if value != nil { - return false - } - case "required", "supports_parallel_tool_calls": - if value != false { - return false - } - default: - return false - } + if expected.EnabledTools != nil && !matchesEnabledTools(server["enabled_tools"], *expected.EnabledTools) { + return false } } return true } +func nilIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +// matchesEnabledTools compares sets: native enabled_tools is an allowlist, not an ordered program. +func matchesEnabledTools(actual any, expected []string) bool { + tools, ok := actual.([]any) + if !ok { + return false + } + want := make(map[string]bool, len(expected)) + for _, tool := range expected { + want[tool] = true + } + got := make(map[string]bool, len(tools)) + for _, tool := range tools { + name, ok := tool.(string) + if !ok { + return false + } + got[name] = true + } + return reflect.DeepEqual(want, got) +} + func matchesMCPHeaderMap(server map[string]any, field string, expected map[string]string) bool { actual, present := server[field] if len(expected) == 0 { - return !present + return !present || actual == nil } want := make(map[string]any, len(expected)) for key, value := range expected { want[key] = value } - if !reflect.DeepEqual(actual, want) { - return false + return reflect.DeepEqual(actual, want) +} + +// matchesMCPList compares an ordered string list; native omits an empty one. +func matchesMCPList(actual any, expected []string) bool { + if len(expected) == 0 { + return actual == nil || reflect.DeepEqual(actual, []any{}) } - delete(server, field) - return true + want := make([]any, len(expected)) + for i, value := range expected { + want[i] = value + } + return reflect.DeepEqual(actual, want) } 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 138a97182..297725749 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -13,7 +13,9 @@ import ( func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { for _, allowlist := range []*[]string{nil, new([]string), {"lookup", "query"}} { servers := map[string]mcpServerConfig{"docs": {URL: "https://docs.example/mcp", EnabledTools: allowlist}} - for _, mutation := range []string{"none", "ambient", "url", "header", "header helper", "env header", "auth", "required", "tools", "disabled", "remote", "plugins", "apps", "keyring", "policy"} { + // Only what binds a credential to the declared server is checked; every + // other native setting and undeclared server keeps its native value. + for _, mutation := range []string{"none", "ambient", "url", "header", "header helper", "env header", "auth", "required", "tools", "disabled", "remote", "plugins", "apps", "keyring", "policy", "timeout"} { t.Run(mutation+"/"+allowlistName(allowlist), func(t *testing.T) { response := mcpHTTPConfigResponse(servers) config := response["config"].(map[string]any) @@ -41,17 +43,20 @@ func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { case "remote": server["environment_id"] = "remote" case "plugins", "apps": - config["features"].(map[string]any)[mutation] = true + config["features"] = map[string]any{mutation: true} case "keyring": config["mcp_oauth_credentials_store"] = "auto" case "policy": server["tools"] = map[string]any{"lookup": map[string]any{"enabled": false}} + case "timeout": + server["tool_timeout_sec"] = 5 } data, err := json.Marshal(response) if err != nil { t.Fatal(err) } - if matchesMCPConfig(data, servers) != (mutation == "none") { + rejected := map[string]bool{"url": true, "header": true, "env header": true, "remote": true, "keyring": true, "tools": allowlist != nil} + if matchesMCPConfig(data, servers) == rejected[mutation] { t.Fatal("effective configuration decision differed", mutation) } }) @@ -122,7 +127,7 @@ func TestPublicMCPHTTPPreflightRedactsNativeErrors(t *testing.T) { } func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { - for _, mode := range []string{"new", "resume", "reject", "reject bearer reference"} { + for _, mode := range []string{"new", "resume", "extra server", "reject url", "reject bearer reference"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.AgentOptions = map[string]any{"model": "fixture-model"} @@ -144,9 +149,12 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Fatal(err) } response := mcpHTTPConfigResponse(declarations) - if mode == "reject" { + if mode == "extra server" { response["config"].(map[string]any)["mcp_servers"].(map[string]any)["operator"] = map[string]any{"url": "https://operator.example/private"} } + if mode == "reject url" { + response["config"].(map[string]any)["mcp_servers"].(map[string]any)["docs"].(map[string]any)["url"] = "https://workspace.example/redirect" + } if mode == "reject bearer reference" { response["config"].(map[string]any)["mcp_servers"].(map[string]any)["docs"].(map[string]any)["bearer_token_env_var"] = "OPERATOR_SECRET" } @@ -156,7 +164,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { e, err := testExecutor(t, "complete", req, cfg) if strings.HasPrefix(mode, "reject") { if err == nil || e != nil { - t.Fatal("ambient MCP configuration admitted") + t.Fatal("redirected credential binding admitted") } assertPreparationOnly(t, root) waitPreparationMethod(t, root, "config/read") @@ -204,3 +212,32 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { }) } } + +func TestPublicMCPStdioReachesNativeConfiguration(t *testing.T) { + tools := []string{"lookup"} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", AllowedTools: &tools, + Stdio: &proto.MCPStdio{Command: "run", Args: []string{"-x"}, Cwd: "/work", EnvVars: []string{"TOKEN"}}}} + declared, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers}) + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + if err := writeCodexMCPConfig(home, declared); err != nil { + t.Fatal(err) + } + config, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{`command = "run"`, `args = ["-x"]`, `cwd = "/work"`, `env_vars = ["TOKEN"]`, `enabled_tools = ["lookup"]`} { + if !strings.Contains(string(config), expected) { + t.Fatalf("missing native config %q", expected) + } + } + read := func(cwd string) json.RawMessage { + return json.RawMessage(`{"config":{"mcp_oauth_credentials_store":"file","mcp_servers":{"local":{"environment_id":"local","command":"run","args":["-x"],"cwd":"` + cwd + `","env_vars":["TOKEN"],"enabled_tools":["lookup"]}}}}`) + } + if !matchesMCPConfig(read("/work"), declared) || matchesMCPConfig(read("/elsewhere"), declared) { + t.Fatal("effective native stdio configuration was not verified") + } +} diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 015c0e939..75610d9fa 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -26,8 +26,10 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatal(err) } defer plan.Cleanup() - 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") + if slices.Contains(plan.DisableFeatures, "apps") || slices.Contains(plan.DisableFeatures, "plugins") || + !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) || + !slices.Contains(plan.ExtraConfig, [2]string{"shell_environment_policy.exclude", `["OAC_RUNTIME_MCP_*"]`}) { + t.Fatal("native profile kept only credential isolation") } home, err := allocCodexHome(testStateRoot(t), req.AgentStateKey) if err != nil { @@ -81,7 +83,6 @@ func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { for _, server := range []proto.MCPHTTPServer{ {ConnectionOrigin: "service", ServerLabel: "codex_apps", ServerURL: "https://docs.example/mcp"}, {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, } { servers := []proto.MCPHTTPServer{server} @@ -121,7 +122,7 @@ func mcpHTTPConfigResponse(servers map[string]mcpServerConfig) map[string]any { } entries[name] = entry } - return map[string]any{"config": map[string]any{"mcp_servers": entries, "features": map[string]any{"plugins": false, "apps": false}, "mcp_oauth_credentials_store": "file"}} + return map[string]any{"config": map[string]any{"mcp_servers": entries, "mcp_oauth_credentials_store": "file"}} } func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_other.go b/apps/daemon/internal/agent/codex/model_catalog_command_other.go deleted file mode 100644 index 38a3fb6ae..000000000 --- a/apps/daemon/internal/agent/codex/model_catalog_command_other.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !unix - -package codex - -import ( - "context" - "errors" - "os/exec" -) - -// SupportsTextVerbosity requires bounded cancellation of the catalog probe. -const SupportsTextVerbosity = false - -func modelCatalogCommand(context.Context, string, ...string) (*exec.Cmd, error) { - return nil, errors.New("codex: text verbosity requires Unix process-group cancellation support") -} diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_unix.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix.go deleted file mode 100644 index 949b3b86e..000000000 --- a/apps/daemon/internal/agent/codex/model_catalog_command_unix.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build unix - -package codex - -import ( - "context" - "os/exec" - "syscall" - "time" -) - -// SupportsTextVerbosity requires bounded cancellation of the catalog probe. -const SupportsTextVerbosity = true - -func modelCatalogCommand(ctx context.Context, binary string, args ...string) (*exec.Cmd, error) { - cmd := exec.CommandContext(ctx, binary, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } - cmd.WaitDelay = time.Second - return cmd, nil -} diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go deleted file mode 100644 index 79b74bf3a..000000000 --- a/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build unix - -package codex - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" -) - -func TestModelCatalogCancellationStopsDescendants(t *testing.T) { - for _, finish := range []string{"wait", "exit 0"} { - t.Run(finish, func(t *testing.T) { - checkCatalogDescendantCancellation(t, finish) - }) - } -} - -func checkCatalogDescendantCancellation(t *testing.T, finish string) { - t.Helper() - started := time.Now() - dir := t.TempDir() - binary := filepath.Join(dir, "codex") - if err := os.WriteFile(binary, []byte("#!/bin/sh\n(sleep 2; printf leaked > leaked) &\nprintf ready > ready\n"+finish+"\n"), 0700); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan error, 1) - go func() { done <- prepareModelVerbosity(ctx, binary, &SessionPlan{Cwd: dir}) }() - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil { - break - } - if time.Now().After(deadline) { - t.Fatal("catalog launcher did not start") - } - time.Sleep(10 * time.Millisecond) - } - cancel() - select { - case err := <-done: - if err == nil { - t.Fatal("cancelled catalog probe succeeded") - } - case <-time.After(1500 * time.Millisecond): - t.Fatal("catalog probe kept waiting on child output pipes") - } - time.Sleep(time.Until(started.Add(2200 * time.Millisecond))) - if _, err := os.Stat(filepath.Join(dir, "leaked")); !os.IsNotExist(err) { - t.Fatalf("catalog child survived cancellation: %v", err) - } -} diff --git a/apps/daemon/internal/agent/codex/model_route_test.go b/apps/daemon/internal/agent/codex/model_route_test.go index c18fecfe9..74ad45684 100644 --- a/apps/daemon/internal/agent/codex/model_route_test.go +++ b/apps/daemon/internal/agent/codex/model_route_test.go @@ -2,6 +2,7 @@ package codex import ( "reflect" + "slices" "strings" "testing" ) @@ -35,3 +36,17 @@ func TestPlanRejectsIncompleteExplicitProvider(t *testing.T) { } } } + +func TestPlanMapsTheContextWindowNatively(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + provider := map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": "fixture", "context_window": 64000} + plan, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model": "m", "model_provider": provider}, nil) + if err != nil || !slices.Contains(plan.ExtraConfig, [2]string{"model_context_window", "64000"}) { + t.Fatalf("context window not native: %v %v", plan.ExtraConfig, err) + } + plan.Cleanup() + provider["max_output_tokens"] = 4096 + if _, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model": "m", "model_provider": provider}, nil); err == nil { + t.Fatal("max_output_tokens without a native home accepted") + } +} diff --git a/apps/daemon/internal/agent/codex/model_verbosity.go b/apps/daemon/internal/agent/codex/model_verbosity.go deleted file mode 100644 index 5bab427be..000000000 --- a/apps/daemon/internal/agent/codex/model_verbosity.go +++ /dev/null @@ -1,129 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "time" -) - -// Validate against the binary's active catalog and use that same snapshot for -// execution. A CLI override alone is silently ignored for unsupported models. -func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan) (resultErr error) { - started := time.Now() - defer func() { observePreparationStage(ctx, "model_catalog", started, resultErr) }() - args := []string{} - for _, kv := range plan.ExtraConfig { - args = append(args, "-c", kv[0]+"="+kv[1]) - } - args = append(args, "debug", "models") - ctx, cancel := context.WithTimeout(ctx, rpcDefaultRequestTimeout) - defer cancel() - cmd, err := modelCatalogCommand(ctx, binary, args...) - if err != nil { - return err - } - cmd.Dir = plan.Cwd - cmd.Env = append(os.Environ(), plan.Env...) - commandStarted := time.Now() - catalog, err := cmd.Output() - commandEnded := time.Now() - // The launcher can exit before its children, ending the context watcher. - cleanupStarted := time.Now() - var cleanupErr error - if cmd.Process != nil { - cleanupErr = cmd.Cancel() - } - cleanupEnded := time.Now() - observePreparationInterval(ctx, "model_catalog_command", commandStarted, commandEnded, err) - // This is the existing best-effort process-group signal, not a second Wait. - if cmd.Process != nil { - observePreparationInterval(ctx, "model_catalog_cleanup", cleanupStarted, cleanupEnded, cleanupErr) - } - if err != nil { - return fmt.Errorf("codex: cannot verify model verbosity support: %w", err) - } - validationStarted := time.Now() - defer func() { - if !validationStarted.IsZero() { - observePreparationStage(ctx, "model_catalog_validation", validationStarted, resultErr) - } - }() - supported, err := catalogSupportsVerbosity(catalog, plan.Model) - if err != nil { - return fmt.Errorf("codex: cannot read model verbosity support: %w", err) - } - if !supported { - if !slices.Contains(plan.ExtraConfig, [2]string{"model_verbosity", `"medium"`}) { - return fmt.Errorf("codex: model %q does not declare text verbosity support", plan.Model) - } - // Protocol medium means the default text amount, which needs no native override. - plan.ExtraConfig = slices.DeleteFunc(plan.ExtraConfig, func(kv [2]string) bool { return kv[0] == "model_verbosity" }) - } - codexHome := nativeHomeFromPlan(*plan) - if !filepath.IsAbs(codexHome) { - return fmt.Errorf("codex: missing managed home for model catalog") - } - observePreparationStage(ctx, "model_catalog_validation", validationStarted, nil) - validationStarted = time.Time{} - snapshotStarted := time.Now() - defer func() { observePreparationStage(ctx, "model_catalog_snapshot", snapshotStarted, resultErr) }() - file, err := os.CreateTemp(codexHome, "model-catalog-*.json") - if err != nil { - return err - } - _, writeErr := file.Write(catalog) - closeErr := file.Close() - if writeErr != nil || closeErr != nil { - _ = os.Remove(file.Name()) - if writeErr != nil { - return writeErr - } - return closeErr - } - cleanup := plan.Cleanup - plan.Cleanup = func() { _ = os.Remove(file.Name()); cleanup() } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_catalog_json", strconv(file.Name())}) - return nil -} - -func catalogSupportsVerbosity(raw []byte, model string) (bool, error) { - var catalog struct { - Models []struct { - Slug string `json:"slug"` - SupportVerbosity bool `json:"support_verbosity"` - } `json:"models"` - } - if err := json.Unmarshal(raw, &catalog); err != nil { - return false, err - } - // Match Codex models-manager's longest-prefix lookup, then its single, - // simple provider namespace fallback. Do not infer support from a model name. - match := func(name string) (bool, bool) { - longest, supported := 0, false - for _, candidate := range catalog.Models { - if len(candidate.Slug) > longest && strings.HasPrefix(name, candidate.Slug) { - longest, supported = len(candidate.Slug), candidate.SupportVerbosity - } - } - return supported, longest > 0 - } - if supported, found := match(model); found { - return supported, nil - } - namespace, suffix, found := strings.Cut(model, "/") - if !found || namespace == "" || strings.Contains(suffix, "/") { - return false, nil - } - for _, c := range namespace { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { - return false, nil - } - } - supported, _ := match(suffix) - return supported, nil -} diff --git a/apps/daemon/internal/agent/codex/model_verbosity_test.go b/apps/daemon/internal/agent/codex/model_verbosity_test.go deleted file mode 100644 index 83a65e502..000000000 --- a/apps/daemon/internal/agent/codex/model_verbosity_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package codex - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestCatalogVerbositySupport(t *testing.T) { - catalog := []byte(`{"models":[{"slug":"gpt-5","support_verbosity":true},{"slug":"gpt-5-special","support_verbosity":false}]}`) - for model, want := range map[string]bool{ - "gpt-5": true, "gpt-5.5": true, "provider/gpt-5.5": true, - "gpt-5-special": false, "provider/gpt-5-special": false, - "custom-provider-model": false, "": false, "a/b/gpt-5": false, "a b/gpt-5": false, - } { - got, err := catalogSupportsVerbosity(catalog, model) - if err != nil || got != want { - t.Errorf("%q: got %v, %v; want %v", model, got, err, want) - } - } - if _, err := catalogSupportsVerbosity([]byte(`{"models":false}`), "gpt-5"); err == nil { - t.Fatal("accepted malformed catalog") - } -} - -func TestPrepareModelVerbosity(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "codex") - catalog := `{"models":[{"slug":"known-model","support_verbosity":true,"native_extra":{"keep":true}}]}` - if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { - t.Fatal(err) - } - plan, err := BuildSessionPlan(testStateRoot(t), "state", map[string]any{"model": "known-model"}, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "high"}) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if err := prepareModelVerbosity(context.Background(), binary, &plan); err != nil { - t.Fatal(err) - } - kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] - if kv[0] != "model_catalog_json" { - t.Fatal("catalog was not pinned") - } - name := strings.Trim(kv[1], `"`) - got, err := os.ReadFile(name) - if err != nil || string(got) != catalog { - t.Fatalf("catalog changed: %s, %v", got, err) - } - plan.Cleanup() - if _, err := os.Stat(name); !os.IsNotExist(err) { - t.Fatalf("catalog was not removed: %v", err) - } - plan.Model = "custom-provider-model" - if err := prepareModelVerbosity(context.Background(), binary, &plan); err == nil { - t.Fatal("accepted model that would ignore verbosity") - } - if err := prepareModelVerbosity(context.Background(), "/missing-codex", &plan); err == nil { - t.Fatal("accepted unreadable catalog") - } -} - -func TestPrepareDefaultModelVerbosity(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "codex") - catalog := `{"models":[{"slug":"supported","support_verbosity":true,"default_verbosity":"low"},{"slug":"unsupported","support_verbosity":false}]}` - if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { - t.Fatal(err) - } - 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(testStateRoot(t), "state", map[string]any{"model": model}, &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: level}) - if err != nil { - t.Fatal(err) - } - defer func() { plan.Cleanup() }() - err = prepareModelVerbosity(context.Background(), binary, &plan) - if model != "supported" && level != "medium" { - if err == nil { - t.Fatal("accepted unsupported non-default verbosity") - } - return - } - if err != nil { - t.Fatal(err) - } - found := false - for _, kv := range plan.ExtraConfig { - if kv[0] == "model_verbosity" { - found = true - if kv[1] != `"`+level+`"` { - t.Fatal("configured verbosity changed", kv) - } - } - } - if found != (model == "supported") || plan.Model != model { - t.Fatal("native default or model identity changed", plan.ExtraConfig, plan.Model) - } - kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] - raw, err := os.ReadFile(strings.Trim(kv[1], `"`)) - if kv[0] != "model_catalog_json" || err != nil || string(raw) != catalog { - t.Fatal("native catalog snapshot changed", err) - } - }) - } - } -} diff --git a/apps/daemon/internal/agent/codex/native_hooks.go b/apps/daemon/internal/agent/codex/native_hooks.go deleted file mode 100644 index 962ca1969..000000000 --- a/apps/daemon/internal/agent/codex/native_hooks.go +++ /dev/null @@ -1,39 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "time" -) - -type nativeToolHook struct { - EventName string `json:"eventName"` - Command string `json:"command"` - Matcher string `json:"matcher"` - Enabled bool `json:"enabled"` - IsManaged bool `json:"isManaged"` - Async bool `json:"async"` - SourcePath string `json:"sourcePath"` - TrustStatus string `json:"trustStatus"` -} - -func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) - if err != nil { - return nil, errors.New("codex: initialized tool hook unavailable") - } - var response struct { - Data []struct { - CWD string `json:"cwd"` - Hooks []nativeToolHook `json:"hooks"` - Errors []json.RawMessage `json:"errors"` - } `json:"data"` - } - if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { - return nil, errors.New("codex: initialized tool hook configuration unavailable") - } - return response.Data[0].Hooks, nil -} diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 0f7f26d26..4a696d994 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "slices" "sort" "strings" @@ -57,6 +56,15 @@ type SessionPlan struct { // ModelReasoningEffort is frozen for launch and every native Turn. ModelReasoningEffort string + // ReasoningSummary reports that the Agent requested reasoning summaries. + ReasoningSummary bool + + // OutputSchema is the frozen public JSON Schema sent as outputSchema on every + // native root Turn. + OutputSchema json.RawMessage + + shellEnvExclude []string + // Cleanup is the deferred housekeeping the session must run after the child exits. Cleanup func() } @@ -69,7 +77,7 @@ type SessionPlan struct { // model_provider object frozen upstream protocol, endpoint, credential and token limits // harness_config object native parameters accepted by harnessconfig/codex // -// Execution controls select the native web_search and model_verbosity settings. +// Execution controls map one to one to native settings; see applyExecutionControls. // The codex binary itself is resolved via PATH only. func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, controls *proto.ExecutionControls) (SessionPlan, error) { plan := SessionPlan{ @@ -81,8 +89,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont Cleanup: func() {}, } - nativeConfig, err := harnessconfiguration.Configuration().PrepareHarnessConfig(opts) - if err != nil { + if _, err := harnessconfiguration.Declaration.Configuration.PrepareHarnessConfig(opts); err != nil { return plan, err } @@ -90,13 +97,8 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont return plan, errors.New("codex: model and complete model_provider are required") } - 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 !slices.Contains([]string{"low", "medium", "high"}, controls.TextVerbosity) { - return plan, errors.New("codex: text_verbosity must be low, medium or high") - } + if err := harnessconfiguration.Declaration.ValidateExecutionControls(controls); err != nil { + return plan, err } plan.Model = stringOpt(opts, "model") @@ -109,7 +111,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont if err := resetGeneratedConfig(codexHome); err != nil { return plan, err } - env := []string{"DISABLE_TELEMETRY=1", "CODEX_HOME=" + codexHome} + env := []string{"CODEX_HOME=" + codexHome} provider, hasProvider, err := normaliseProviderConfig(opts["model_provider"]) if err != nil { @@ -124,7 +126,10 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont // shell_environment_policy is applied. Do not persist provider credentials. plan.DisableFeatures = append(plan.DisableFeatures, "shell_snapshot") env = append(env, providerAPIKeyEnv+"="+provider.BearerToken) - excluded := []string{strconv(providerAPIKeyEnv)} + excludeFromShellEnvironment(&plan, providerAPIKeyEnv) + if provider.ContextWindow > 0 { + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_context_window", fmt.Sprint(provider.ContextWindow)}) + } names := make([]string, 0, len(provider.HTTPHeaders)) for name := range provider.HTTPHeaders { names = append(names, name) @@ -133,19 +138,12 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont for i, name := range names { reference := providerHeaderEnv(i) env = append(env, reference+"="+provider.HTTPHeaders[name]) - excluded = append(excluded, strconv(reference)) + excludeFromShellEnvironment(&plan, reference) } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"shell_environment_policy.exclude", "[" + strings.Join(excluded, ",") + "]"}) } plan.Env = env - 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)}) - } + applyExecutionControls(&plan, controls) if plan.ModelProvider != "" { // Pin model_provider at the CLI layer so codex skips its builtin // "openai" provider — without this the [model_providers.oac] @@ -157,6 +155,73 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont return plan, nil } +// excludeFromShellEnvironment keeps a Runtime secret out of native shell tools. +// The pattern is a native case-insensitive wildcard; one setting holds them all. +func excludeFromShellEnvironment(plan *SessionPlan, pattern string) { + const key = "shell_environment_policy.exclude" + plan.shellEnvExclude = append(plan.shellEnvExclude, strconv(pattern)) + value := "[" + strings.Join(plan.shellEnvExclude, ",") + "]" + for i := range plan.ExtraConfig { + if plan.ExtraConfig[i][0] == key { + plan.ExtraConfig[i][1] = value + return + } + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{key, value}) +} + +// applyExecutionControls maps each official value to its native setting. An +// absent control sends nothing, so Codex keeps its native default; Codex itself +// ignores a value the selected model does not support. +func applyExecutionControls(plan *SessionPlan, controls *proto.ExecutionControls) { + set := func(key, value string) { plan.ExtraConfig = append(plan.ExtraConfig, [2]string{key, value}) } + if controls == nil || controls.ReasoningSummary == "" { + // The official default is no reasoning summary, while Codex defaults to a model-dependent one. + set("model_reasoning_summary", strconv("none")) + } + if controls == nil { + return + } + if search := controls.WebSearch; search != nil { + set("web_search", strconv(search.Mode)) + set("tools.web_search.context_size", strconv(search.ContextSize)) + if search.AllowedDomains != nil { + quoted := make([]string, len(search.AllowedDomains)) + for i, domain := range search.AllowedDomains { + quoted[i] = strconv(domain) + } + set("tools.web_search.allowed_domains", "["+strings.Join(quoted, ",")+"]") + } + if location := search.Location; location != nil { + for _, field := range []struct { + key string + value *string + }{{"city", location.City}, {"country", location.Country}, {"region", location.Region}, {"timezone", location.Timezone}} { + if field.value != nil { + set("tools.web_search.location."+field.key, strconv(*field.value)) + } + } + } + } + if format := controls.OutputFormat; format != nil { + plan.OutputSchema = format.Schema + } + if controls.ReasoningEffort != "" { + plan.ModelReasoningEffort = controls.ReasoningEffort + set("model_reasoning_effort", strconv(controls.ReasoningEffort)) + } + if controls.ReasoningSummary != "" { + plan.ReasoningSummary = true + set("model_reasoning_summary", strconv(controls.ReasoningSummary)) + } + if controls.ServiceTier != "" { + set("service_tier", strconv(controls.ServiceTier)) + } + if controls.TextVerbosity != "" { + set("model_verbosity", strconv(controls.TextVerbosity)) + } +} + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- @@ -211,11 +276,11 @@ func normaliseProviderConfig(raw any) (providerConfig, bool, error) { if raw == nil { return providerConfig{}, false, nil } - provider, err := harnessconfiguration.Configuration().ParseProvider(raw) + provider, err := harnessconfiguration.Declaration.Configuration.ParseProvider(raw) if err != nil { return providerConfig{}, false, err } - return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses", HTTPHeaders: provider.Headers}, true, nil + return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses", ContextWindow: provider.ContextWindow, HTTPHeaders: provider.Headers}, true, nil } func stringOpt(opts map[string]any, key string) string { diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index eb64d84de..a63c5db19 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -28,21 +28,17 @@ func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { } defer plan.Cleanup() hasCodexHome := false - hasTelemetry := false for _, kv := range plan.Env { switch { case strings.HasPrefix(kv, "CODEX_HOME="): hasCodexHome = true - case kv == "DISABLE_TELEMETRY=1": - hasTelemetry = true + case strings.HasPrefix(kv, "DISABLE_TELEMETRY="): + t.Fatalf("env sets a variable Codex does not read: %+v", plan.Env) } } if !hasCodexHome { t.Fatalf("env missing CODEX_HOME: %+v", plan.Env) } - if !hasTelemetry { - t.Fatalf("env missing DISABLE_TELEMETRY: %+v", plan.Env) - } } func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/permission_profile.go b/apps/daemon/internal/agent/codex/permission_profile.go index f5e7cb807..6ffcef841 100644 --- a/apps/daemon/internal/agent/codex/permission_profile.go +++ b/apps/daemon/internal/agent/codex/permission_profile.go @@ -7,8 +7,8 @@ import ( // Runtime execution uses the current user; isolation belongs to its outer host. 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") + if req.LocalEnvironment != nil && req.WorkspaceReadOnly { + return fmt.Errorf("codex: Runtime execution requires a writable workspace") } 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 c8bd52e71..9ff776baf 100644 --- a/apps/daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/daemon/internal/agent/codex/permission_profile_test.go @@ -11,7 +11,7 @@ import ( func TestRuntimeUsesHostPermissions(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) { - req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()}} + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{WorkspaceRoot: t.TempDir()}} plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{stateRoot: testStateRoot(t)}) if err != nil { t.Fatal(err) @@ -25,12 +25,6 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { } } plan.Cleanup() - for _, network := range []string{"disabled", "restricted"} { - req.LocalEnvironment.NetworkAccess = network - if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{stateRoot: testStateRoot(t)}); err == nil { - t.Fatal("unsupported network admitted") - } - } } } @@ -45,10 +39,10 @@ func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { if err := os.WriteFile(config, []byte(`{"CODEX_HOME":"wrong","HOME":"wrong","USERPROFILE":"wrong","USER_VALUE":"ready"}`), 0600); err != nil { t.Fatal(err) } - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_TOOL_ENV_FILE": config} { + for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_TOOL_ENV_FILE": config} { t.Setenv(key, value) } - req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{}} plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{stateRoot: testStateRoot(t)}) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 468e08576..5b54cc40f 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -13,9 +13,6 @@ import ( ) 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") - } if req.WorkspaceReadOnly { return nil, errors.New("codex: workspace reads use the local Runtime interface") } @@ -62,7 +59,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg s := &Session{ nativeHome: nativeHomeFromPlan(plan), functions: functions, - observeMessages: req.ObserveMessages, observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, rpc: rpc, @@ -72,6 +68,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg cleanup: sync.OnceFunc(plan.Cleanup), bufs: NewItemBuffers(), resolvedModel: plan.Model, + reasoningSummary: plan.ReasoningSummary, } plan.Cleanup = s.cleanup p := &Prepared{ @@ -89,8 +86,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } verificationStarted := time.Now() defer func() { observePreparationStage(parent, "verification", verificationStarted, resultErr) }() - if req.ExecutionControls != nil && req.ExecutionControls.DisableProgrammaticToolCalling { - if err := verifyProgrammaticToolsDisabled(cancelCtx, rpc); err != nil { + if req.ExecutionControls != nil && req.ExecutionControls.ProgrammaticToolCalling != nil { + if err := verifyProgrammaticTools(cancelCtx, rpc, *req.ExecutionControls.ProgrammaticToolCalling); err != nil { return p.preparationFailed(err) } } @@ -100,7 +97,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } } if s.observeSubagentIdentities { - if err := verifySubagentObservationProfile(cancelCtx, rpc, plan.Cwd); err != nil { + if err := verifySubagentObservationProfile(cancelCtx, rpc); err != nil { return p.preparationFailed(err) } } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 1472c3a5d..780febc73 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -42,7 +42,7 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", AgentOptions: map[string]any{"model": "fixture-model"}, - ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, + ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, DisableExecutionEnvironment: true, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"integer"}}}`)}}, } diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index 35f2b64f0..f1a3086d3 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -46,7 +46,6 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { "OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", } { t.Setenv(key, value) } @@ -54,9 +53,9 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { if err != nil { t.Fatal(err) } - req.AgentStateKey = "agents-api-" + session + req.AgentStateKey = session req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", 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{}) prepared := make(chan *Prepared, 1) diff --git a/apps/daemon/internal/agent/codex/programmatic_tools.go b/apps/daemon/internal/agent/codex/programmatic_tools.go index c2da9f9a1..52dac4668 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools.go @@ -11,8 +11,17 @@ import ( var programmaticFeatures = []string{"code_mode", "code_mode_only", "code_mode_prewarm"} -func disableProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionControls) { - if controls == nil || !controls.DisableProgrammaticToolCalling { +// applyProgrammaticTools maps the official programmatic_tool_calling.enabled +// value to Codex code mode, whose model-written JavaScript calls the other +// tools. Disabling also turns off its dependent features. Omission keeps the +// native default. +func applyProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionControls) { + if controls == nil || controls.ProgrammaticToolCalling == nil { + return + } + if *controls.ProgrammaticToolCalling { + plan.EnableFeatures = append(plan.EnableFeatures, "code_mode") + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.code_mode", "true"}) return } for _, feature := range programmaticFeatures { @@ -25,7 +34,7 @@ func disableProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionContro // Managed native requirements can override command-line feature settings. // Check before starting or resuming a native Session, not after model execution. -func verifyProgrammaticToolsDisabled(ctx context.Context, rpc *JSONRPCClient) error { +func verifyProgrammaticTools(ctx context.Context, rpc *JSONRPCClient, enabled bool) error { raw, err := rpc.Request(ctx, "configRequirements/read", nil) var response struct { Requirements json.RawMessage `json:"requirements"` @@ -40,6 +49,12 @@ func verifyProgrammaticToolsDisabled(ctx context.Context, rpc *JSONRPCClient) er if json.Unmarshal(response.Requirements, &requirements) != nil { return errors.New("codex: invalid programmatic tool requirements") } + if enabled { + if required, pinned := requirements.Features["code_mode"]; pinned && !required { + return errors.New("codex: required programmatic tools conflict with explicit enabling") + } + return nil + } for _, feature := range programmaticFeatures { if requirements.Features[feature] { return errors.New("codex: required programmatic tools conflict with explicit disabling") diff --git a/apps/daemon/internal/agent/codex/programmatic_tools_test.go b/apps/daemon/internal/agent/codex/programmatic_tools_test.go index fd0aeee8a..920d58720 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools_test.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools_test.go @@ -3,6 +3,7 @@ package codex import ( "context" "encoding/json" + "fmt" "slices" "testing" "time" @@ -10,13 +11,19 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { +func TestProgrammaticToolsFollowTheOfficialValue(t *testing.T) { + enabled, disabled := true, false plan := SessionPlan{ExtraConfig: [][2]string{{"features.code_mode", "true"}}} - disableProgrammaticTools(&plan, nil) - if len(plan.DisableFeatures) != 0 || len(plan.ExtraConfig) != 1 { + applyProgrammaticTools(&plan, nil) + applyProgrammaticTools(&plan, &proto.ExecutionControls{}) + if len(plan.DisableFeatures) != 0 || len(plan.EnableFeatures) != 0 || len(plan.ExtraConfig) != 1 { t.Fatal("omission changed native configuration") } - disableProgrammaticTools(&plan, &proto.ExecutionControls{DisableProgrammaticToolCalling: true}) + applyProgrammaticTools(&plan, &proto.ExecutionControls{ProgrammaticToolCalling: &enabled}) + if !slices.Equal(plan.EnableFeatures, []string{"code_mode"}) || len(plan.DisableFeatures) != 0 || plan.ExtraConfig[len(plan.ExtraConfig)-1] != [2]string{"features.code_mode", "true"} { + t.Fatal("enabling did not select code mode", plan) + } + applyProgrammaticTools(&plan, &proto.ExecutionControls{ProgrammaticToolCalling: &disabled}) for _, feature := range programmaticFeatures { if !slices.Contains(plan.DisableFeatures, feature) { t.Fatal("missing disable", feature) @@ -36,22 +43,26 @@ func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { func TestProgrammaticToolsRejectManagedOverridesBeforeExecution(t *testing.T) { for _, tc := range []struct { response string + enabled bool accepted bool }{ - {`{"requirements":null}`, true}, - {`{"requirements":{"featureRequirements":{"code_mode":false,"unrelated":true}}}`, true}, - {`{"requirements":{"featureRequirements":{"code_mode":true}}}`, false}, - {`{"requirements":{"featureRequirements":{"code_mode_only":true}}}`, false}, - {`{"requirements":{"featureRequirements":{"code_mode_prewarm":true}}}`, false}, - {`{}`, false}, {`null`, false}, {`{"requirements":[]}`, false}, + {`{"requirements":null}`, false, true}, + {`{"requirements":{"featureRequirements":{"code_mode":false,"unrelated":true}}}`, false, true}, + {`{"requirements":{"featureRequirements":{"code_mode":true}}}`, false, false}, + {`{"requirements":{"featureRequirements":{"code_mode_only":true}}}`, false, false}, + {`{"requirements":{"featureRequirements":{"code_mode_prewarm":true}}}`, false, false}, + {`{}`, false, false}, {`null`, false, false}, {`{"requirements":[]}`, false, false}, + {`{"requirements":null}`, true, true}, + {`{"requirements":{"featureRequirements":{"code_mode":true}}}`, true, true}, + {`{"requirements":{"featureRequirements":{"code_mode":false}}}`, true, false}, } { - t.Run(tc.response, func(t *testing.T) { + t.Run(fmt.Sprint(tc.enabled, tc.response), func(t *testing.T) { client, server, cleanup := NewTestClient() defer cleanup() ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() done := make(chan error, 1) - go func() { done <- verifyProgrammaticToolsDisabled(ctx, client.JSONRPCClient) }() + go func() { done <- verifyProgrammaticTools(ctx, client.JSONRPCClient, tc.enabled) }() var request struct{ ID, Method string } if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 2daa4acb7..2e9bd8a89 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -164,24 +164,10 @@ type UserInput struct { } type TurnStartParams struct { - ThreadID string `json:"threadId"` - Input []UserInput `json:"input"` - CollaborationMode *CollaborationMode `json:"collaborationMode,omitempty"` -} - -type CollaborationModeKind string - -const CollaborationModeDefault CollaborationModeKind = "default" - -type CollaborationMode struct { - Mode CollaborationModeKind `json:"mode"` - Settings CollaborationModeSettings `json:"settings"` -} - -type CollaborationModeSettings struct { - ReasoningEffort string `json:"reasoning_effort,omitempty"` - Model string `json:"model"` - DeveloperInstructions *string `json:"developer_instructions"` + ThreadID string `json:"threadId"` + Input []UserInput `json:"input"` + // OutputSchema constrains this Turn's final assistant message natively. + OutputSchema json.RawMessage `json:"outputSchema,omitempty"` } type TurnInterruptParams struct { @@ -230,11 +216,10 @@ type ThreadItem struct { ID string `json:"id,omitempty"` // agentMessage / reasoning - Phase string `json:"phase,omitempty"` - Text string `json:"text,omitempty"` - Summary []string `json:"summary,omitempty"` - Content []string `json:"content,omitempty"` - SummaryText string `json:"summary_text,omitempty"` + Phase string `json:"phase,omitempty"` + Text string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + Content []string `json:"content,omitempty"` // commandExecution Command string `json:"command,omitempty"` @@ -297,7 +282,12 @@ type AgentMessageDeltaNotification struct { Delta string `json:"delta"` } -type ReasoningDeltaNotification = AgentMessageDeltaNotification +// ReasoningDeltaNotification is a summaryTextDelta (SummaryIndex) or a textDelta (ContentIndex). +type ReasoningDeltaNotification struct { + AgentMessageDeltaNotification + SummaryIndex int `json:"summaryIndex"` + ContentIndex int `json:"contentIndex"` +} type ThreadTokenUsageUpdatedNotification struct { ThreadID string `json:"threadId"` diff --git a/apps/daemon/internal/agent/codex/protocol_wire_test.go b/apps/daemon/internal/agent/codex/protocol_wire_test.go index 895888bab..d22371f20 100644 --- a/apps/daemon/internal/agent/codex/protocol_wire_test.go +++ b/apps/daemon/internal/agent/codex/protocol_wire_test.go @@ -49,29 +49,3 @@ func TestThreadStartParams_ModelProviderIsCamelCaseField(t *testing.T) { t.Fatalf("snake_case model_provider leaked: %s", body) } } - -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: CollaborationModeDefault, - Settings: CollaborationModeSettings{ - Model: "MiniMax-M3", - DeveloperInstructions: &developerInstructions, - }, - }, - } - raw, err := json.Marshal(params) - if err != nil { - t.Fatalf("marshal: %v", err) - } - body := string(raw) - 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"`) { - t.Fatalf("snake_case collaboration_mode leaked: %s", body) - } -} diff --git a/apps/daemon/internal/agent/codex/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go index 42ca09b38..8bd145254 100644 --- a/apps/daemon/internal/agent/codex/provider_config.go +++ b/apps/daemon/internal/agent/codex/provider_config.go @@ -41,6 +41,8 @@ type providerConfig struct { // WireAPI is "responses" (codex Responses API) or "chat" (rejected // upstream since codex-rs 0.140). Empty defaults to "responses". WireAPI string + // ContextWindow is the native model_context_window when set. + ContextWindow int32 } // writeCodexProviderConfig writes (or appends to) /config.toml diff --git a/apps/daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index a68dbb7e9..6be2d4d83 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -304,9 +304,10 @@ func TestProviderHeadersRebuiltForEveryTurn(t *testing.T) { if !slices.Contains(plan.Env, "OAC_CODEX_MODEL_HEADER_0=private-header-canary") { t.Fatal("missing header environment") } + excludeFromShellEnvironment(&plan, "OAC_CODEX_MCP_HEADER_*") found := false for _, option := range plan.ExtraConfig { - if option[0] == "shell_environment_policy.exclude" && strings.Contains(option[1], "OAC_CODEX_MODEL_HEADER_0") { + if option[0] == "shell_environment_policy.exclude" && strings.Contains(option[1], "OAC_CODEX_MODEL_HEADER_0") && strings.Contains(option[1], providerAPIKeyEnv) && strings.Contains(option[1], "OAC_CODEX_MCP_HEADER_*") { found = true } } diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index c4ea41f01..f859c93e4 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -158,7 +158,7 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { t.Fatal(err) } req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}, WorkspaceRoot: cwd} + req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}, WorkspaceRoot: cwd} } e, err := testExecutor(t, "complete", req, cfg) if err != nil { diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 44ae605bb..207514baa 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -63,7 +63,6 @@ type Session struct { subagents *subagentObservations observeSubagentIdentities bool functions *functionCalls - observeMessages bool runID string cfg sessionConfig @@ -88,8 +87,7 @@ type Session struct { threadID string steering steeringTurn - deltaSeq atomic.Uint64 - thinkingSeq atomic.Uint64 + deltaSeq atomic.Uint64 bufs *ItemBuffers @@ -101,6 +99,8 @@ type Session struct { resumeUsageThreadID string resumeUsageTotal *TurnUsage resolvedModel string + // reasoningSummary reports that the Agent requested reasoning summaries. + reasoningSummary bool finalTextMu sync.Mutex finalText string @@ -127,8 +127,8 @@ func (s *Session) registerHandlers() { rpc.OnNotification("item/completed", s.onItemCompleted) rpc.OnNotification("item/agentMessage/delta", s.onAgentDelta) rpc.OnNotification("item/commandExecution/outputDelta", s.onCommandOutput) - rpc.OnNotification("item/reasoning/textDelta", s.onReasoningDelta) - rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) + rpc.OnNotification("item/reasoning/textDelta", func(raw json.RawMessage) { s.onReasoningDelta("text", raw) }) + rpc.OnNotification("item/reasoning/summaryTextDelta", func(raw json.RawMessage) { s.onReasoningDelta("summary", raw) }) rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) @@ -145,21 +145,61 @@ func (s *Session) onTurnStarted(raw json.RawMessage) { } } -func (s *Session) onReasoningDelta(raw json.RawMessage) { - var p AgentMessageDeltaNotification +// onReasoningDelta streams the first native channel a reasoning item delivers: +// its summary, or the raw reasoning text of a model that has no summary. Each +// native part is reported as its own part. +func (s *Session) onReasoningDelta(channel string, raw json.RawMessage) { + var p ReasoningDeltaNotification if err := json.Unmarshal(raw, &p); err != nil { return } - if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { + if !s.reasoningSummary || !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { return } - _ = FoldDeltaIntoBuffer(s.bufs, "reasoning", p.ItemID, p.Delta) - seq := s.thinkingSeq.Add(1) - env, err := proto.NewEnvelope(proto.TypeThinking, s.runID, proto.ThinkingPayload{Text: p.Delta, Sequence: seq}) - if err != nil { + native := p.ContentIndex + if channel == "summary" { + native = p.SummaryIndex + } + stream, started := s.bufs.Reasoning[p.ItemID] + if started && stream.channel != channel { return } - s.trySend(env) + if started && stream.native != native { + stream.part++ + } + stream.channel, stream.native = channel, native + s.bufs.Reasoning[p.ItemID] = stream + s.sendReasoning(p.ItemID, "in_progress", stream.part, p.Delta) +} + +// completeReasoning ends a reasoning item. An item that streamed nothing is +// reported whole from its completed body; one with no text is not reported. +func (s *Session) completeReasoning(item ThreadItem) { + if !s.reasoningSummary { + return + } + _, streamed := s.bufs.Reasoning[item.ID] + delete(s.bufs.Reasoning, item.ID) + if !streamed { + part := 0 + for _, text := range nativeReasoningParts(item) { + if text != "" { + s.sendReasoning(item.ID, "in_progress", part, text) + part++ + } + } + if part == 0 { + return + } + } + s.sendReasoning(item.ID, "completed", 0, "") +} + +func (s *Session) sendReasoning(id, status string, part int, delta string) { + env, err := proto.NewEnvelope(proto.TypeReasoning, s.runID, proto.ReasoningPayload{ID: id, Status: status, Index: part, Delta: delta}) + if err == nil { + s.trySend(env) + } } func (s *Session) onTurnCompleted(raw json.RawMessage) { @@ -337,7 +377,7 @@ func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int6 return } s.stopSteering() - s.stopFunctionCalls() + s.stopFunctionCalls(s.subagents != nil) doneMeta := map[string]any{} if tid := s.currentThreadID(); tid != "" { doneMeta[proto.DoneMetaAgentSessionID] = tid @@ -377,7 +417,7 @@ func (s *Session) emitTerminalFailure(message string, asError bool, failure prot return } s.stopSteering() - s.stopFunctionCalls() + s.stopFunctionCalls(s.subagents != nil) // Keep safe correlation at terminal settlement; native bodies stay private. if asError { s.cfg.logger.Warn("codex: emitting terminal error", diff --git a/apps/daemon/internal/agent/codex/session_cancel.go b/apps/daemon/internal/agent/codex/session_cancel.go index 091eae119..a8e30ae65 100644 --- a/apps/daemon/internal/agent/codex/session_cancel.go +++ b/apps/daemon/internal/agent/codex/session_cancel.go @@ -40,7 +40,7 @@ func (s *Session) Cancel(ctx context.Context) error { func (s *Session) cancelNativeWork() { defer close(s.cancelReady) - s.stopFunctionCalls() + s.stopFunctionCalls(false) turnID, active := s.stopSteering() // Best effort: a known Turn must use its native identity. An explicit // empty ID invokes native startup cancellation before turn/started. diff --git a/apps/daemon/internal/agent/codex/session_items.go b/apps/daemon/internal/agent/codex/session_items.go index 3cb3db01d..c1cd6a3cf 100644 --- a/apps/daemon/internal/agent/codex/session_items.go +++ b/apps/daemon/internal/agent/codex/session_items.go @@ -3,27 +3,30 @@ package codex import ( "encoding/json" "fmt" - "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// ItemBuffers holds the per-itemId text accumulators used to fold -// delta notifications back into a single chunk on item/completed. -// Codex streams reasoning and agentMessage in two channels — the -// {reasoning,agentMessage}/delta notifications, plus the final item -// body on item/completed. We keep both: deltas drive incremental UI -// (TypeDelta / TypeThinking), and completed-item bodies anchor the -// final text for the done event. +// ItemBuffers holds per-itemId state for streamed notifications. +// agentMessage deltas drive incremental TypeDelta frames while the completed +// item anchors the final text. A reasoning item streams one of its two native +// channels, recorded here from its first delta. type ItemBuffers struct { - Reasoning map[string]string + Reasoning map[string]reasoningStream AgentText map[string]string } +// reasoningStream is the native channel a reasoning item streams, the last +// native part index seen on it and the reported part it maps to. +type reasoningStream struct { + channel string + native, part int +} + // NewItemBuffers returns an empty buffer set. func NewItemBuffers() *ItemBuffers { return &ItemBuffers{ - Reasoning: map[string]string{}, + Reasoning: map[string]reasoningStream{}, AgentText: map[string]string{}, } } @@ -36,7 +39,7 @@ func NewItemBuffers() *ItemBuffers { // item types we silence by intent: // // - reasoning / agentMessage / userMessage: text is streamed via deltas -// and re-stamped in item/completed +// and re-stamped in item/completed; reasoning is reported by the Session // - error: surfaced by turn/completed.status="failed" instead func DispatchStartedItem(runID string, item ThreadItem) ([]proto.Envelope, error) { switch item.Type { @@ -69,34 +72,14 @@ func DispatchStartedItem(runID string, item ThreadItem) ([]proto.Envelope, error } // DispatchCompletedItem folds an item.completed payload into envelopes. -// Reasoning and agentMessage produce a final TypeThinking / TypeDelta -// (delta+sequence=0) so the buffer drained by upstream deltas can be -// flushed; tool-call variants produce an "after" envelope so the UI -// gets a stage transition. -// -// emitFinalDelta=true asks the dispatch to emit a synthetic full-text -// TypeDelta whose Sequence will be set by the caller using a session- -// level monotonic counter; this is only needed when no deltas were -// observed (item.completed arrived without any item/agentMessage/delta). +// An agentMessage returns its text so the session can stamp it into +// DonePayload.Content; tool-call variants produce an "after" envelope so the +// UI gets a stage transition. // // Returns the agent text body for agentMessage items so the session can // stamp it into DonePayload.Content. Empty string for everything else. func DispatchCompletedItem(runID string, item ThreadItem, bufs *ItemBuffers) (envelopes []proto.Envelope, agentText string, err error) { switch item.Type { - case "reasoning": - body := bufs.Reasoning[item.ID] - delete(bufs.Reasoning, item.ID) - if body == "" { - body = fallbackReasoningText(item) - } - if body == "" { - return nil, "", nil - } - env, err := proto.NewEnvelope(proto.TypeThinking, runID, proto.ThinkingPayload{Text: body}) - if err != nil { - return nil, "", fmt.Errorf("codex: thinking envelope: %w", err) - } - return []proto.Envelope{env}, "", nil case "agentMessage": body := bufs.AgentText[item.ID] delete(bufs.AgentText, item.ID) @@ -123,22 +106,14 @@ func DispatchCompletedItem(runID string, item ThreadItem, bufs *ItemBuffers) (en return nil, "", nil } -// FoldDeltaIntoBuffer accumulates a delta string under the matching -// itemId. Returns the running prefix so the caller can emit a -// progressive TypeDelta / TypeThinking envelope for streaming UI. -func FoldDeltaIntoBuffer(bufs *ItemBuffers, kind, itemID, delta string) string { +// FoldDeltaIntoBuffer accumulates an agentMessage delta under its itemId and +// returns the running text. +func FoldDeltaIntoBuffer(bufs *ItemBuffers, itemID, delta string) string { if delta == "" || itemID == "" { return "" } - switch kind { - case "agent": - bufs.AgentText[itemID] += delta - return bufs.AgentText[itemID] - case "reasoning": - bufs.Reasoning[itemID] += delta - return bufs.Reasoning[itemID] - } - return "" + bufs.AgentText[itemID] += delta + return bufs.AgentText[itemID] } func toolNameForItem(item ThreadItem) string { @@ -203,32 +178,11 @@ func argMap(v any) map[string]any { return map[string]any{"value": json.RawMessage(raw)} } -func fallbackReasoningText(item ThreadItem) string { - if item.Text != "" { - return item.Text - } - if item.SummaryText != "" { - return item.SummaryText - } +// nativeReasoningParts is what Codex shows for a completed reasoning item: its +// summary when it has one, otherwise its raw reasoning text. +func nativeReasoningParts(item ThreadItem) []string { if len(item.Summary) > 0 { - var b strings.Builder - for i, s := range item.Summary { - if i > 0 { - b.WriteString("\n\n") - } - b.WriteString(s) - } - return b.String() - } - if len(item.Content) > 0 { - var b strings.Builder - for i, s := range item.Content { - if i > 0 { - b.WriteString("\n\n") - } - b.WriteString(s) - } - return b.String() + return item.Summary } - return "" + return item.Content } diff --git a/apps/daemon/internal/agent/codex/session_items_test.go b/apps/daemon/internal/agent/codex/session_items_test.go index 8ba3334b7..95ce60d56 100644 --- a/apps/daemon/internal/agent/codex/session_items_test.go +++ b/apps/daemon/internal/agent/codex/session_items_test.go @@ -1,7 +1,9 @@ package codex import ( + "context" "encoding/json" + "reflect" "strings" "testing" @@ -66,54 +68,46 @@ func TestDispatchStartedItem_UnknownSilent(t *testing.T) { } } -func TestDispatchCompletedItem_Reasoning_UsesBuffer(t *testing.T) { - bufs := NewItemBuffers() - FoldDeltaIntoBuffer(bufs, "reasoning", "r1", "Hello ") - FoldDeltaIntoBuffer(bufs, "reasoning", "r1", "world") - - envs, text, err := DispatchCompletedItem("run-1", ThreadItem{ - Type: "reasoning", ID: "r1", - }, bufs) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - if text != "" { - t.Fatalf("reasoning must not produce agent text, got %q", text) - } - if len(envs) != 1 || envs[0].Type != proto.TypeThinking { - t.Fatalf("envs = %+v", envs) - } - var p proto.ThinkingPayload - _ = envs[0].DecodePayload(&p) - if p.Text != "Hello world" { - t.Fatalf("thinking text = %q", p.Text) - } - // Buffer must be drained so a re-completion doesn't double-emit. - if got := bufs.Reasoning["r1"]; got != "" { - t.Fatalf("buffer not drained: %q", got) - } -} - -func TestDispatchCompletedItem_Reasoning_FallbackToItemBody(t *testing.T) { - // No deltas observed — must fall back to item.text / summary. - bufs := NewItemBuffers() - envs, _, err := DispatchCompletedItem("run-1", ThreadItem{ - Type: "reasoning", ID: "r1", Text: "fallback body", - }, bufs) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - var p proto.ThinkingPayload - _ = envs[0].DecodePayload(&p) - if p.Text != "fallback body" { - t.Fatalf("fallback path = %q", p.Text) +func TestReasoningIsReportedOnlyWhenRequested(t *testing.T) { + for _, requested := range []bool{true, false} { + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(testStateRoot(t)), + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), reasoningSummary: requested} + s.registerHandlers() + s.setThreadID("root") + scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) + scope := `{"threadId":"root","turnId":"turn",` + scopeNotification(t, s, "item/reasoning/summaryTextDelta", scope+`"itemId":"r1","delta":"plan ","summaryIndex":0}`) + scopeNotification(t, s, "item/reasoning/textDelta", scope+`"itemId":"r1","delta":"raw","contentIndex":0}`) + scopeNotification(t, s, "item/reasoning/summaryTextDelta", scope+`"itemId":"r1","delta":"next","summaryIndex":1}`) + scopeNotification(t, s, "item/completed", scope+`"item":{"type":"reasoning","id":"r1"}}`) + scopeNotification(t, s, "item/completed", scope+`"item":{"type":"reasoning","id":"r2","content":["whole"]}}`) + close(out) + var got []proto.ReasoningPayload + for env := range out { + if env.Type == proto.TypeReasoning { + var p proto.ReasoningPayload + _ = env.DecodePayload(&p) + got = append(got, p) + } + } + want := []proto.ReasoningPayload{ + {ID: "r1", Status: "in_progress", Delta: "plan "}, {ID: "r1", Status: "in_progress", Index: 1, Delta: "next"}, {ID: "r1", Status: "completed"}, + {ID: "r2", Status: "in_progress", Delta: "whole"}, {ID: "r2", Status: "completed"}, + } + if !requested { + want = nil + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("requested=%v: reasoning %+v, want %+v", requested, got, want) + } } } func TestDispatchCompletedItem_AgentMessage_BufferIsFinalText(t *testing.T) { bufs := NewItemBuffers() - FoldDeltaIntoBuffer(bufs, "agent", "a1", "Hello ") - FoldDeltaIntoBuffer(bufs, "agent", "a1", "world") + FoldDeltaIntoBuffer(bufs, "a1", "Hello ") + FoldDeltaIntoBuffer(bufs, "a1", "world") envs, text, err := DispatchCompletedItem("run-1", ThreadItem{ Type: "agentMessage", ID: "a1", Text: "fallback", @@ -168,14 +162,14 @@ func TestDispatchCompletedItem_ToolCallEmitsAfterStage(t *testing.T) { func TestFoldDeltaIntoBuffer_AccumulatesPerItem(t *testing.T) { bufs := NewItemBuffers() - if got := FoldDeltaIntoBuffer(bufs, "agent", "a1", "hi"); got != "hi" { + if got := FoldDeltaIntoBuffer(bufs, "a1", "hi"); got != "hi" { t.Fatalf("fold returned %q", got) } - if got := FoldDeltaIntoBuffer(bufs, "agent", "a1", " there"); got != "hi there" { + if got := FoldDeltaIntoBuffer(bufs, "a1", " there"); got != "hi there" { t.Fatalf("fold accumulation = %q", got) } // Different itemId is a different accumulator. - if got := FoldDeltaIntoBuffer(bufs, "agent", "a2", "other"); got != "other" { + if got := FoldDeltaIntoBuffer(bufs, "a2", "other"); got != "other" { t.Fatalf("cross-item bleed: got %q", got) } } diff --git a/apps/daemon/internal/agent/codex/session_messages.go b/apps/daemon/internal/agent/codex/session_messages.go index 840485d46..4d7642f36 100644 --- a/apps/daemon/internal/agent/codex/session_messages.go +++ b/apps/daemon/internal/agent/codex/session_messages.go @@ -17,13 +17,9 @@ func (s *Session) onAgentDelta(raw json.RawMessage) { return } first := s.boundary.text(received, p.TurnID) - _ = FoldDeltaIntoBuffer(s.bufs, "agent", p.ItemID, p.Delta) + _ = FoldDeltaIntoBuffer(s.bufs, p.ItemID, p.Delta) seq := s.deltaSeq.Add(1) - payload := proto.DeltaPayload{Delta: p.Delta, Sequence: seq} - if s.observeMessages { - payload.ItemID = p.ItemID - } - env, err := proto.NewEnvelope(proto.TypeDelta, s.runID, payload) + env, err := proto.NewEnvelope(proto.TypeDelta, s.runID, proto.DeltaPayload{ItemID: p.ItemID, Delta: p.Delta, Sequence: seq}) if err != nil { return } @@ -57,11 +53,15 @@ func (s *Session) onItemCompleted(raw json.RawMessage) { if err := json.Unmarshal(raw, &p); err != nil { return } + // Record native application before observation backpressure can delay publication. + s.confirmFunctionResult(raw) if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Item.ID == "" { return } - // Record native application before observation backpressure can delay publication. - s.confirmFunctionResult(raw) + if p.Item.Type == "reasoning" { + s.completeReasoning(p.Item) + return + } envs, text, err := DispatchCompletedItem(s.runID, p.Item, s.bufs) if err != nil { s.cfg.logger.Warn("codex: dispatch completed item failed", "run_id", s.runID, "err", err) @@ -77,7 +77,7 @@ func (s *Session) onItemCompleted(raw json.RawMessage) { first = s.boundary.text(received, p.TurnID) } sent := s.observeMessage(p.Item, "completed", &messageText) - if first && s.observeMessages { + if first { s.boundary.forward(received, sent) } if text != "" { @@ -86,7 +86,7 @@ func (s *Session) onItemCompleted(raw json.RawMessage) { } func (s *Session) observeMessage(item ThreadItem, status string, text *string) bool { - if !s.observeMessages || item.Type != "agentMessage" { + if item.Type != "agentMessage" { return false } env, err := proto.NewEnvelope(proto.TypeOutputMessage, s.runID, proto.OutputMessagePayload{ID: item.ID, Status: status, Phase: item.Phase, Text: text}) diff --git a/apps/daemon/internal/agent/codex/session_messages_test.go b/apps/daemon/internal/agent/codex/session_messages_test.go index 1e09975fb..eacc37199 100644 --- a/apps/daemon/internal/agent/codex/session_messages_test.go +++ b/apps/daemon/internal/agent/codex/session_messages_test.go @@ -8,65 +8,55 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestMessageObservationIsOptInAndKeepsNativeBoundaries(t *testing.T) { - for _, enabled := range []bool{false, true} { - t.Run(map[bool]string{false: "legacy", true: "observed"}[enabled], func(t *testing.T) { - out := make(chan proto.Envelope, 16) - s := &Session{runID: "run", observeMessages: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig(testStateRoot(t))} - s.setThreadID("thread") - s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) - s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary"}}`)) - s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"a","delta":"first"}`)) - s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"b","phase":"final_answer"}}`)) - s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"b","delta":"second"}`)) - s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary","text":"first complete"}}`)) - // b stays unfinished, as when the native request is cancelled before item/completed. - s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"c","phase":"final_answer","text":"without deltas"}}`)) - var deltas []proto.DeltaPayload - var messages []proto.OutputMessagePayload - for len(out) > 0 { - env := <-out - switch env.Type { - case proto.TypeDelta: - var p proto.DeltaPayload - if err := env.DecodePayload(&p); err != nil { - t.Fatal(err) - } - deltas = append(deltas, p) - case proto.TypeOutputMessage: - var p proto.OutputMessagePayload - if err := env.DecodePayload(&p); err != nil { - t.Fatal(err) - } - messages = append(messages, p) - default: - t.Fatalf("unexpected frame: %s", env.Type) - } - } - if len(deltas) != 2 || deltas[0].Delta != "first" || deltas[1].Delta != "second" || deltas[1].Sequence != 2 { - t.Fatalf("legacy text changed: %+v", deltas) - } - if !enabled { - if len(messages) != 0 || deltas[0].ItemID != "" || deltas[1].ItemID != "" { - t.Fatal("legacy request gained observations") - } - return - } - if deltas[0].ItemID != "a" || deltas[1].ItemID != "b" || len(messages) != 4 { - t.Fatalf("identity lost: %+v %+v", deltas, messages) - } - if messages[0].ID != "a" || messages[0].Phase != "commentary" || messages[0].Status != "in_progress" || messages[0].Text != nil { - t.Fatalf("start: %+v", messages[0]) - } - if messages[1].ID != "b" || messages[1].Phase != "final_answer" || messages[1].Status != "in_progress" { - t.Fatalf("second start: %+v", messages[1]) - } - if messages[2].ID != "a" || messages[2].Status != "completed" || messages[2].Text == nil || *messages[2].Text != "first complete" { - t.Fatalf("completion: %+v", messages[2]) - } - if messages[3].ID != "c" || messages[3].Text == nil || *messages[3].Text != "without deltas" { - t.Fatalf("non-streamed message lost: %+v", messages[3]) - } - }) +func TestMessageObservationKeepsNativeBoundaries(t *testing.T) { + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig(testStateRoot(t))} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary"}}`)) + s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"a","delta":"first"}`)) + s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"b","phase":"final_answer"}}`)) + s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"b","delta":"second"}`)) + s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary","text":"first complete"}}`)) + // b stays unfinished, as when the native request is cancelled before item/completed. + s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"c","phase":"final_answer","text":"without deltas"}}`)) + var deltas []proto.DeltaPayload + var messages []proto.OutputMessagePayload + for len(out) > 0 { + env := <-out + switch env.Type { + case proto.TypeDelta: + var p proto.DeltaPayload + if err := env.DecodePayload(&p); err != nil { + t.Fatal(err) + } + deltas = append(deltas, p) + case proto.TypeOutputMessage: + var p proto.OutputMessagePayload + if err := env.DecodePayload(&p); err != nil { + t.Fatal(err) + } + messages = append(messages, p) + default: + t.Fatalf("unexpected frame: %s", env.Type) + } + } + if len(deltas) != 2 || deltas[0].Delta != "first" || deltas[1].Delta != "second" || deltas[1].Sequence != 2 { + t.Fatalf("text changed: %+v", deltas) + } + if deltas[0].ItemID != "a" || deltas[1].ItemID != "b" || len(messages) != 4 { + t.Fatalf("identity lost: %+v %+v", deltas, messages) + } + if messages[0].ID != "a" || messages[0].Phase != "commentary" || messages[0].Status != "in_progress" || messages[0].Text != nil { + t.Fatalf("start: %+v", messages[0]) + } + if messages[1].ID != "b" || messages[1].Phase != "final_answer" || messages[1].Status != "in_progress" { + t.Fatalf("second start: %+v", messages[1]) + } + if messages[2].ID != "a" || messages[2].Status != "completed" || messages[2].Text == nil || *messages[2].Text != "first complete" { + t.Fatalf("completion: %+v", messages[2]) + } + if messages[3].ID != "c" || messages[3].Text == nil || *messages[3].Text != "without deltas" { + t.Fatalf("non-streamed message lost: %+v", messages[3]) } } diff --git a/apps/daemon/internal/agent/codex/session_notifications_test.go b/apps/daemon/internal/agent/codex/session_notifications_test.go index 2c1f1092b..3e33d7885 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_test.go @@ -13,7 +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(testStateRoot(t)), - rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true} + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), reasoningSummary: true} s.registerHandlers() s.setThreadID("root") notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } @@ -57,7 +57,7 @@ func TestRootNotificationIsolation(t *testing.T) { if s.terminal.Load() || s.currentThreadID() != "root" || len(out) != rootEvents { t.Fatalf("foreign notification changed Run ownership/output: terminal=%v thread=%q events=%d want=%d", s.terminal.Load(), s.currentThreadID(), len(out), rootEvents) } - if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"] != "root thought" || s.peekLastErrText() != "root retry" || s.takeFinalText() != "" { + if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"].channel != "text" || s.peekLastErrText() != "root retry" || s.takeFinalText() != "" { t.Fatal("foreign notification changed root buffers") } if s.latestUsage == nil || s.latestUsage.InputTokens != 20 || s.latestUsage.OutputTokens != 5 || s.usageTurnID != "root-turn" { @@ -88,8 +88,8 @@ func TestRootNotificationIsolation(t *testing.T) { } } } - if doneCount != 1 || s.deltaSeq.Load() != 2 || s.thinkingSeq.Load() != 1 { - t.Fatalf("root output duplicated or polluted: done=%d delta=%d thinking=%d", doneCount, s.deltaSeq.Load(), s.thinkingSeq.Load()) + if doneCount != 1 || s.deltaSeq.Load() != 2 { + t.Fatalf("root output duplicated or polluted: done=%d delta=%d", doneCount, s.deltaSeq.Load()) } }) } diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index b917805f4..fe240ec20 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -28,9 +28,13 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Cleanup() return SessionPlan{}, nil, err } - disableProgrammaticTools(&plan, req.ExecutionControls) + applyProgrammaticTools(&plan, req.ExecutionControls) if req.LocalEnvironment != nil { plan.Cwd = req.LocalEnvironment.WorkspaceRoot + if err := writeWorkspaceTrust(nativeHomeFromPlan(plan), plan.Cwd); err != nil { + plan.Cleanup() + return SessionPlan{}, nil, err + } } else { // environment:none has no workspace; the Session's private home is its cwd. plan.Cwd = nativeHomeFromPlan(plan) @@ -60,22 +64,11 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } } - if req.ExecutionControls != nil { - if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - } - if req.DisableExecutionEnvironment { plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") } var skillRoots []string if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { - if err := verifyHostedSkills(req.LocalEnvironment.Skills); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } for _, skill := range req.LocalEnvironment.Skills { skillRoots = append(skillRoots, localworkspace.SkillPath(skill)) } diff --git a/apps/daemon/internal/agent/codex/session_run.go b/apps/daemon/internal/agent/codex/session_run.go index a341d8ce8..48e3002ff 100644 --- a/apps/daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -3,7 +3,6 @@ package codex import ( "context" "fmt" - "strings" "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -29,24 +28,9 @@ func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.P return err } turnParams := TurnStartParams{ - ThreadID: s.currentThreadID(), - Input: input, - } - 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, - }, + ThreadID: s.currentThreadID(), + Input: input, + OutputSchema: plan.OutputSchema, } turnCtx, turnCancel := context.WithTimeout(ctx, 10*time.Second) _, ackErr := s.rpc.requestWithResult(withNativeBoundary(turnCtx, s.boundary), "turn/start", turnParams, s.bindTurnResult) diff --git a/apps/daemon/internal/agent/codex/session_steering.go b/apps/daemon/internal/agent/codex/session_steering.go index 53fabc084..c3f000389 100644 --- a/apps/daemon/internal/agent/codex/session_steering.go +++ b/apps/daemon/internal/agent/codex/session_steering.go @@ -25,19 +25,8 @@ type TurnSteerParams struct { Input []UserInput `json:"input"` } -var _ agent.Steerer = (*Session)(nil) - -// Steer returns success only after Codex accepts input for this native turn. -func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return s.steer(ctx, input, nil) -} - // SteerWithReceipt waits under the Run context after reporting the complete write. func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { - return s.steer(ctx, input, written) -} - -func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, written func()) error { if !s.beginOperation() { return agent.ErrSteeringInactive } @@ -67,19 +56,13 @@ func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, wri return agent.ErrSteeringRejected } params := TurnSteerParams{ThreadID: threadID, ExpectedTurnID: turnID, Input: content} - timeout := s.rpc.cfg.RequestTimeout - if written != nil { - timeout = 0 - } raw, err := s.rpc.requestWithTimeout(ctx, "turn/steer", params, func(frame any) error { if err := s.rpc.writeFrameContext(ctx, frame); err != nil { return err } - if written != nil { - written() - } + written() return nil - }, timeout, nil) + }, 0, nil) if err != nil { var rejected *JsonRpcError if errors.As(err, &rejected) { 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 6dc663ea0..8cbaa7863 100644 --- a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -25,9 +25,9 @@ func TestSteeringReceiptTimeoutAndCompletionKeepProcessAlive(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - done := make(chan error, 1) + done, written := make(chan error, 1), make(chan struct{}) go func() { - done <- s.Steer(ctx, proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("extra")}) + done <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("extra")}, func() { close(written) }) }() var request JsonRpcRequest if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { @@ -36,19 +36,10 @@ func TestSteeringReceiptTimeoutAndCompletionKeepProcessAlive(t *testing.T) { // Withhold the response after reading the entire request frame. if complete { // Reading the pipe does not mean its writer has returned yet. - for { - client.pendingMu.Lock() - pending := client.pending[request.ID] - waiting := pending != nil && pending.timer != nil - client.pendingMu.Unlock() - if waiting { - break - } - select { - case <-ctx.Done(): - t.Fatal("steering request did not reach response wait") - case <-time.After(time.Millisecond): - } + select { + case <-written: + case <-ctx.Done(): + t.Fatal("steering request did not reach response wait") } s.onTurnCompleted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn","status":"completed"}}`)) } @@ -127,7 +118,7 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { } callCtx, callCancel := context.WithTimeout(ctx, 50*time.Millisecond) defer callCancel() - if err := s.Steer(callCtx, proto.PromptSteerPayload{InputID: "blocked", Input: proto.TextInput("extra")}); err == nil { + if err := s.SteerWithReceipt(callCtx, proto.PromptSteerPayload{InputID: "blocked", Input: proto.TextInput("extra")}, func() {}); err == nil { t.Fatal("blocked write accepted") } if client.Alive() { diff --git a/apps/daemon/internal/agent/codex/session_steering_receipt_test.go b/apps/daemon/internal/agent/codex/session_steering_receipt_test.go index bb14579d9..7432577ce 100644 --- a/apps/daemon/internal/agent/codex/session_steering_receipt_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_receipt_test.go @@ -10,66 +10,46 @@ import ( ) func TestDurableSteeringBypassesOnlyNativeResponseDeadline(t *testing.T) { - for _, durable := range []bool{false, true} { - t.Run(map[bool]string{false: "legacy", true: "durable"}[durable], func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - client.cfg.RequestTimeout = 20 * time.Millisecond - s := &Session{rpc: client.JSONRPCClient, cancelCtx: context.Background()} - s.setThreadID("thread") - s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - written := make(chan struct{}) - reply := make(chan error, 1) - go func() { - input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("text")} - if durable { - reply <- s.SteerWithReceipt(ctx, input, func() { close(written) }) - } else { - reply <- s.Steer(ctx, input) - } - }() - if durable { - select { - case <-written: - t.Fatal("written before frame was read") - default: - } - } - var request JsonRpcRequest - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if durable { - select { - case <-written: - case <-ctx.Done(): - t.Fatal("missing write phase") - } - } - select { - case err := <-reply: - if durable || err == nil { - t.Fatal("unexpected response deadline", err) - } - case <-time.After(60 * time.Millisecond): - if !durable { - t.Fatal("legacy timeout disappeared") - } - } - if durable { - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]string{"turnId": "turn"}}); err != nil { - t.Fatal(err) - } - if err := <-reply; err != nil { - t.Fatal(err) - } - } - if !client.Alive() { - t.Fatal("receipt wait killed process") - } - }) + client, server, cleanup := NewTestClient() + defer cleanup() + client.cfg.RequestTimeout = 20 * time.Millisecond + s := &Session{rpc: client.JSONRPCClient, cancelCtx: context.Background()} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + written := make(chan struct{}) + reply := make(chan error, 1) + go func() { + reply <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("text")}, func() { close(written) }) + }() + select { + case <-written: + t.Fatal("written before frame was read") + default: + } + var request JsonRpcRequest + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + select { + case <-written: + case <-ctx.Done(): + t.Fatal("missing write phase") + } + select { + case err := <-reply: + t.Fatal("unexpected response deadline", err) + case <-time.After(60 * time.Millisecond): + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]string{"turnId": "turn"}}); err != nil { + t.Fatal(err) + } + if err := <-reply; err != nil { + t.Fatal(err) + } + if !client.Alive() { + t.Fatal("receipt wait killed process") } } diff --git a/apps/daemon/internal/agent/codex/session_steering_test.go b/apps/daemon/internal/agent/codex/session_steering_test.go index 1fe0fcc47..524398938 100644 --- a/apps/daemon/internal/agent/codex/session_steering_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_test.go @@ -33,7 +33,7 @@ func TestSteeringUsesNativeActiveTurnAndReceipt(t *testing.T) { s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"native-turn"}}`)) done := make(chan error, 1) go func() { - done <- s.Steer(ctx, proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("追加输入")}) + done <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("追加输入")}, func() {}) }() var request struct { ID string `json:"id"` @@ -78,7 +78,7 @@ func TestSteeringDeadlineReleasesBlockedNativeWrite(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - done <- s.Steer(ctx, proto.PromptSteerPayload{InputID: "blocked", Input: proto.TextInput("extra")}) + done <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "blocked", Input: proto.TextInput("extra")}, func() {}) }() // No reader drains the pipe, so the request never reaches its response wait. select { @@ -103,18 +103,18 @@ func TestSteeringDoesNotStartOrReviveTurns(t *testing.T) { // A nil RPC client proves these states do not issue a request. for _, notification := range []json.RawMessage{nil, json.RawMessage(`{"threadId":"other-thread","turn":{"id":"other-turn"}}`)} { s.onTurnStarted(notification) - if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringNotReady) { + if err := s.SteerWithReceipt(ctx, input, func() {}); !errors.Is(err, agent.ErrSteeringNotReady) { t.Fatalf("starting turn: %v", err) } } s.stopSteering() s.onTurnStarted(json.RawMessage(`{"threadId":"native-thread","turn":{"id":"late-turn"}}`)) - if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { + if err := s.SteerWithReceipt(ctx, input, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatalf("terminal turn revived: %v", err) } s = &Session{cancelCtx: ctx} cancel() - if err := s.Steer(context.Background(), input); !errors.Is(err, agent.ErrSteeringInactive) { + if err := s.SteerWithReceipt(context.Background(), input, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatalf("cancelled run: %v", err) } } diff --git a/apps/daemon/internal/agent/codex/session_thread.go b/apps/daemon/internal/agent/codex/session_thread.go index 35699b8a8..65b59fce9 100644 --- a/apps/daemon/internal/agent/codex/session_thread.go +++ b/apps/daemon/internal/agent/codex/session_thread.go @@ -42,13 +42,9 @@ func (s *Session) startThread(plan SessionPlan) error { func (s *Session) resumeThread(threadID string, plan SessionPlan) error { params := ThreadResumeParams{ - ThreadID: threadID, ApprovalPolicy: approvalPolicyNever, Sandbox: sandboxDangerFullAccess, + ThreadID: threadID, Cwd: plan.Cwd, ApprovalPolicy: approvalPolicyNever, Sandbox: sandboxDangerFullAccess, DeveloperInstructions: plan.SystemPrompt, } - if plan.mcpServers != nil { - // Resolve the same project configuration checked before native startup. - params.Cwd = plan.Cwd - } s.usageMu.Lock() s.resumeUsageThreadID = threadID s.usageMu.Unlock() diff --git a/apps/daemon/internal/agent/codex/startup_observation_test.go b/apps/daemon/internal/agent/codex/startup_observation_test.go index 15d758233..82daa0eb2 100644 --- a/apps/daemon/internal/agent/codex/startup_observation_test.go +++ b/apps/daemon/internal/agent/codex/startup_observation_test.go @@ -129,66 +129,6 @@ func TestFirstOutputIgnoresEmptyWrites(t *testing.T) { } } -func TestCatalogObservationsPreserveFailureBoundaries(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - for _, test := range []struct { - name, script, failedStage string - }{ - {"success", `printf '%s' '{"models":[{"slug":"known","support_verbosity":true}]}'`, ""}, - {"nonzero", `printf '%s' '{"models":[]}'; exit 7`, "model_catalog_command"}, - {"invalid", `printf '%s' '{"models":'`, "model_catalog_validation"}, - {"unsupported", `printf '%s' '{"models":[]}'`, "model_catalog_validation"}, - {"snapshot_failure", `printf '%s' '{"models":[{"slug":"known","support_verbosity":true}]}'`, "model_catalog_snapshot"}, - {"spawn_failure", "", "model_catalog_command"}, - } { - t.Run(test.name, func(t *testing.T) { - output := captureStartupLogs(t) - home := t.TempDir() - if test.name == "snapshot_failure" { - home = filepath.Join(home, "missing") - } - plan := SessionPlan{Model: "known", Cwd: t.TempDir(), Env: []string{"CODEX_HOME=" + home}, ExtraConfig: [][2]string{{"model_verbosity", `"high"`}}, Cleanup: func() {}} - defer func() { plan.Cleanup() }() - binary := filepath.Join(t.TempDir(), "probe") - content := "#!/bin/sh\n" + test.script + "\n" - if test.name == "spawn_failure" { - content = "#!/missing-interpreter\n" - } - if err := os.WriteFile(binary, []byte(content), 0700); err != nil { - t.Fatal(err) - } - err := prepareModelVerbosity(t.Context(), binary, &plan) - if (err != nil) != (test.failedStage != "") { - t.Fatal(err) - } - stages := startupStages(t, output) - if stages["model_catalog"]["success"] != (err == nil) { - t.Fatal(stages) - } - if test.failedStage != "" && stages[test.failedStage]["success"] != false { - t.Fatal(stages) - } - if test.failedStage == "model_catalog_command" && stages["model_catalog_validation"] != nil { - t.Fatal("validated failed command") - } - if test.name == "spawn_failure" && stages["model_catalog_cleanup"] != nil { - t.Fatal("unstarted process reported cleanup") - } - if test.name == "success" && stages["model_catalog_cleanup"]["success"] != false { - t.Fatal("already-exited group signal did not report its error", stages) - } - if test.failedStage == "model_catalog_validation" && stages["model_catalog_snapshot"] != nil { - t.Fatal("persisted invalid catalog") - } - if strings.Contains(output.String(), "models") || strings.Contains(output.String(), home) { - t.Fatal("catalog or path leaked") - } - }) - } -} - func TestInitializeObservationDoesNotBlockResponseDelivery(t *testing.T) { reader, writer := io.Pipe() defer reader.Close() diff --git a/apps/daemon/internal/agent/codex/subagent_observations.go b/apps/daemon/internal/agent/codex/subagent_observations.go index 29d1dcaf3..dda5bd36d 100644 --- a/apps/daemon/internal/agent/codex/subagent_observations.go +++ b/apps/daemon/internal/agent/codex/subagent_observations.go @@ -113,6 +113,7 @@ func (s *Session) collectSubagentFacts() { return } firstFailure = time.Time{} + s.sendHeldFunctionCalls(o.ctx) if terminal != nil && !busy { s.publishSubagentTerminal(terminal, nil) return @@ -129,6 +130,7 @@ func (s *Session) publishSubagentTerminal(events []proto.Envelope, cause error) for _, event := range events { s.trySend(event) } + s.stopFunctionCalls(false) s.closeOut() } diff --git a/apps/daemon/internal/agent/codex/subagent_profile.go b/apps/daemon/internal/agent/codex/subagent_profile.go index 68afc9626..cf7093ab5 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile.go +++ b/apps/daemon/internal/agent/codex/subagent_profile.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "slices" stdstrconv "strconv" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -14,14 +13,8 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay if !req.ObserveSubagentIdentities || req.DisableSubagents { return nil } - for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { - if !slices.Contains(plan.DisableFeatures, feature) { - plan.DisableFeatures = append(plan.DisableFeatures, feature) - } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features." + feature, "false"}) - } plan.EnableFeatures = append(plan.EnableFeatures, "multi_agent") - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.multi_agent", "true"}, [2]string{"agents.max_depth", "64"}) + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.multi_agent", "true"}) if req.MaxConcurrentSubagents != nil { if *req.MaxConcurrentSubagents < 1 { return errors.New("codex: invalid subagent concurrency limit") @@ -31,38 +24,24 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay return nil } -func verifySubagentObservationProfile(ctx context.Context, rpc *JSONRPCClient, cwd string) error { - hooks, err := readNativeToolHooks(ctx, rpc, cwd) - if err != nil { - return err - } - if len(hooks) != 0 { - return errors.New("codex: subagent observation requires hooks disabled") - } - +// Code-mode collaboration calls and multi_agent_v2 leave no persisted receipt, so +// managed requirements that force them cannot be observed. +func verifySubagentObservationProfile(ctx context.Context, rpc *JSONRPCClient) error { raw, err := rpc.Request(ctx, "configRequirements/read", nil) var response struct { Requirements *struct { - AllowManagedHooksOnly bool `json:"allowManagedHooksOnly"` - Features map[string]bool `json:"featureRequirements"` + Features map[string]bool `json:"featureRequirements"` } `json:"requirements"` } if err != nil || json.Unmarshal(raw, &response) != nil { - return errors.New("codex: subagent hook requirements unavailable") + return errors.New("codex: subagent feature requirements unavailable") } if response.Requirements == nil { - if len(hooks) != 0 { - return errors.New("codex: managed-only subagent hooks are not enforced") - } return nil } - requirements := response.Requirements - if (len(hooks) != 0 || requirements.Features["hooks"]) && !requirements.AllowManagedHooksOnly { - return errors.New("codex: managed-only subagent hooks are not enforced") - } - for _, feature := range []string{"plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { - if requirements.Features[feature] { - return errors.New("codex: required feature conflicts with subagent observation profile") + for _, feature := range []string{"code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { + if response.Requirements.Features[feature] { + return errors.New("codex: required feature conflicts with subagent observation") } } return nil diff --git a/apps/daemon/internal/agent/codex/subagent_profile_test.go b/apps/daemon/internal/agent/codex/subagent_profile_test.go index 14ee5aa5a..ae96346b0 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/daemon/internal/agent/codex/subagent_profile_test.go @@ -8,19 +8,15 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { +func TestSubagentProfileEnablesOnlyMultiAgent(t *testing.T) { limit := 6 plan := SessionPlan{} if err := configureSubagentObservations(&plan, proto.PromptRequestPayload{ObserveSubagentIdentities: true, MaxConcurrentSubagents: &limit}); err != nil { t.Fatal(err) } - for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { - if slices.Contains(plan.EnableFeatures, feature) || !slices.Contains(plan.DisableFeatures, feature) { - t.Fatal(feature, plan) - } - } - if !slices.Contains(plan.ExtraConfig, [2]string{"agents.max_threads", "6"}) || !slices.Contains(plan.ExtraConfig, [2]string{"agents.max_depth", "64"}) { - t.Fatal(plan.ExtraConfig) + want := [][2]string{{"features.multi_agent", "true"}, {"agents.max_threads", "6"}} + if !slices.Equal(plan.EnableFeatures, []string{"multi_agent"}) || len(plan.DisableFeatures) != 0 || !slices.Equal(plan.ExtraConfig, want) { + t.Fatal(plan) } } @@ -35,26 +31,26 @@ func TestSubagentProfileDoesNotDisableRequiredToolEnvironment(t *testing.T) { } } -func TestSubagentProfileRejectsRequirementsThatPermitResultRewrite(t *testing.T) { +func TestSubagentProfileRejectsRequirementsThatForceUnobservableFeatures(t *testing.T) { for _, test := range []struct { name string requirements any allowed bool }{ - {"ordinary disabled hooks", nil, true}, - {"managed-only hooks", map[string]any{"allowManagedHooksOnly": true, "featureRequirements": map[string]bool{"hooks": true}}, true}, - {"unrestricted forced hooks", map[string]any{"allowManagedHooksOnly": false, "featureRequirements": map[string]bool{"hooks": true}}, false}, + {"no requirements", nil, true}, + {"forced hooks", map[string]any{"featureRequirements": map[string]bool{"hooks": true}}, true}, + {"forced plugins", map[string]any{"featureRequirements": map[string]bool{"plugins": true}}, true}, {"forced code mode", map[string]any{"featureRequirements": map[string]bool{"code_mode": true}}, false}, - {"forced plugins", map[string]any{"featureRequirements": map[string]bool{"plugins": true}}, false}, + {"forced multi_agent_v2", map[string]any{"featureRequirements": map[string]bool{"multi_agent_v2": true}}, false}, } { t.Run(test.name, func(t *testing.T) { client, server, cleanup := NewTestClient() defer cleanup() done := make(chan error, 1) - go func() { done <- verifySubagentObservationProfile(t.Context(), client.JSONRPCClient, "/workspace") }() + go func() { done <- verifySubagentObservationProfile(t.Context(), client.JSONRPCClient) }() decoder := json.NewDecoder(server.FromClient) encoder := json.NewEncoder(server.ToClient) - for _, result := range []any{map[string]any{"data": []any{map[string]any{"cwd": "/workspace", "hooks": []any{}, "errors": []any{}}}}, map[string]any{"requirements": test.requirements}} { + for _, result := range []any{map[string]any{"requirements": test.requirements}} { var request JsonRpcRequest if err := decoder.Decode(&request); err != nil { t.Fatal(err) @@ -69,3 +65,29 @@ func TestSubagentProfileRejectsRequirementsThatPermitResultRewrite(t *testing.T) }) } } + +func TestSubagentItemSnapshotSkipsUnmappedItemTypes(t *testing.T) { + for _, item := range []string{`{"type":"imageView","id":"i1","path":"/x.png"}`, `{"type":"contextCompaction","id":"c1"}`} { + kind, _, _, err := subagentItemSnapshot(json.RawMessage(item), "completed", true) + if err != nil || kind != "" { + t.Fatal(item, kind, err) + } + } +} + +func TestSubagentReasoningIsReportedOnlyWhenRequestedWithText(t *testing.T) { + for _, test := range []struct { + item string + requested bool + want string + }{ + {`{"type":"reasoning","id":"r1","summary":["plan","next"]}`, true, `{"status":"completed","summary":[{"text":"plan","type":"summary_text"},{"text":"next","type":"summary_text"}]}`}, + {`{"type":"reasoning","id":"r1","summary":["plan"]}`, false, ""}, + {`{"type":"reasoning","id":"r1"}`, true, ""}, + } { + kind, body, _, err := subagentItemSnapshot(json.RawMessage(test.item), "completed", test.requested) + if err != nil || string(body) != test.want || (kind == "") != (test.want == "") { + t.Fatal(test.item, test.requested, kind, string(body), err) + } + } +} diff --git a/apps/daemon/internal/agent/codex/subagent_snapshots.go b/apps/daemon/internal/agent/codex/subagent_snapshots.go index 9f3eda46c..2dcca64ad 100644 --- a/apps/daemon/internal/agent/codex/subagent_snapshots.go +++ b/apps/daemon/internal/agent/codex/subagent_snapshots.go @@ -185,7 +185,7 @@ func (s *Session) publishSubagentHistory(ctx context.Context, h subagentHistory, if turn.CompletedItems[identity.ID] { itemStatus = "completed" } - kind, body, id, err := subagentItemSnapshot(raw, itemStatus) + kind, body, id, err := subagentItemSnapshot(raw, itemStatus, s.reasoningSummary) if kind == proto.TypeSubagentCoordination { value, decodeErr := subagentCoordination(raw, h.ID) if decodeErr != nil { @@ -218,6 +218,9 @@ func (s *Session) publishSubagentHistory(ctx context.Context, h subagentHistory, if err := s.sendSubagentFact(ctx, proto.TypeSubagentTurn, key, payload); err != nil { return false, err } + if err := s.withdrawChildCalls(ctx, h.ID, turn.ID); err != nil { + return false, err + } } } return busy || nativeStatus.Type == "active", nil @@ -231,7 +234,7 @@ func nativeMilliseconds(seconds *int64) *int64 { return &ms } -func subagentItemSnapshot(raw json.RawMessage, turnStatus string) (string, json.RawMessage, string, error) { +func subagentItemSnapshot(raw json.RawMessage, turnStatus string, reasoningSummary bool) (string, json.RawMessage, string, error) { var item struct { Type string `json:"type"` ID string `json:"id"` @@ -288,15 +291,22 @@ func subagentItemSnapshot(raw json.RawMessage, turnStatus string) (string, json. body, err := json.Marshal(map[string]any{"input": []any{map[string]any{"type": "message", "role": "user", "content": content}}}) return "message", body, item.ID, err case "reasoning": - var source struct { - Summary []string `json:"summary"` + if !reasoningSummary { + return "", nil, "", nil } + var source ThreadItem if json.Unmarshal(raw, &source) != nil { return "", nil, "", errors.New("codex: invalid child reasoning") } - summary := make([]map[string]string, 0, len(source.Summary)) - for _, text := range source.Summary { - summary = append(summary, map[string]string{"type": "summary_text", "text": text}) + var summary []map[string]string + for _, text := range nativeReasoningParts(source) { + if text != "" { + summary = append(summary, map[string]string{"type": "summary_text", "text": text}) + } + } + if len(summary) == 0 { + // A reasoning item with no text is not reported, as in the root projection. + return "", nil, "", nil } status := "in_progress" if turnStatus == "completed" { @@ -307,6 +317,7 @@ func subagentItemSnapshot(raw json.RawMessage, turnStatus string) (string, json. body, err := json.Marshal(map[string]any{"status": status, "summary": summary}) return "reasoning", body, item.ID, err default: - return "", nil, "", errors.New("codex: unsupported child Item observation") + // Item types without a neutral vocabulary are skipped, as in the root projection. + return "", nil, "", nil } } diff --git a/apps/daemon/internal/agent/codex/subagent_tools_native_test.go b/apps/daemon/internal/agent/codex/subagent_tools_native_test.go new file mode 100644 index 000000000..4d0840434 --- /dev/null +++ b/apps/daemon/internal/agent/codex/subagent_tools_native_test.go @@ -0,0 +1,131 @@ +package codex + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// This opt-in test runs the real pinned harness with a model that spawns a +// Subagent, which calls a function or an MCP tool of the Agent. +func TestSubagentToolsNative(t *testing.T) { + binary, options := nativeAcceptanceConfig(t) + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var rpc struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.Unmarshal(body, &rpc) + var result string + switch rpc.Method { + case "initialize": + result = `{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"proof","version":"0"}}` + case "tools/list": + result = `{"tools":[{"name":"echo","description":"Return the synthetic MCP value.","inputSchema":{"type":"object","properties":{}}}]}` + case "tools/call": + result = `{"content":[{"type":"text","text":"MCP-5521"}]}` + default: + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(rpc.ID) + `,"result":` + result + `}`)) + })) + defer mcpServer.Close() + for name, prompt := range map[string]string{ + "function": "Call spawn_agent with fork_context true and the task: call the lookup function once and report the value it returns. Then wait for that Subagent and reply with its value. Do not call lookup yourself.", + "mcp": "Call spawn_agent with a task: call the echo tool of the proof MCP server once and report the value it returns. Then wait for that Subagent and reply with its value. Do not call echo yourself.", + } { + t.Run(name, func(t *testing.T) { + cfg := defaultSessionConfig(testStateRoot(t)) + cfg.codexBinary = binary + cfg.logger = slog.New(slog.DiscardHandler) + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "proof", ServerURL: mcpServer.URL}} + req := proto.PromptRequestPayload{ + AgentKind: "codex", AgentStateKey: "subagent-tools-" + name, + DisableExecutionEnvironment: true, ObserveSubagentIdentities: true, + AgentOptions: options, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Return the synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)}}, + MCPHTTPServers: &servers, + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + e, err := newExecutor(ctx, req, cfg) + if err != nil { + t.Fatalf("native prepare failed (%T)", err) + } + defer e.Close(context.Background()) + output := make(chan proto.Envelope, 512) + turn, err := e.StartTurn(ctx, name, proto.TextInput(prompt), output) + if err != nil { + t.Fatal(err) + } + var calls []proto.FunctionCallPayload + var childTools []string + var answer string + var answers sync.WaitGroup + for event := range output { + switch event.Type { + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + _ = event.DecodePayload(&call) + calls = append(calls, call) + text := "FN-4729" + answers.Add(1) + go func() { + defer answers.Done() + if err := turn.(agent.FunctionResultSubmitter).SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}); err != nil { + t.Errorf("function answer: %v", err) + } + }() + case proto.TypeSubagentItem: + var item proto.SubagentItemPayload + _ = event.DecodePayload(&item) + var tool struct { + Stage string `json:"stage"` + Observation struct { + Kind, Name, Server string + } `json:"observation"` + } + if item.Kind == proto.TypeToolCall && json.Unmarshal(item.Payload, &tool) == nil && tool.Stage == "after" { + childTools = append(childTools, tool.Observation.Kind+":"+tool.Observation.Server+tool.Observation.Name) + } + case proto.TypeDone: + var done proto.DonePayload + _ = event.DecodePayload(&done) + answer = done.Content + } + } + answers.Wait() + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + t.Logf("function_calls=%+v child_tools=%v answer=%q", calls, childTools, answer) + want := map[string]string{"function": "function:lookup", "mcp": "mcp:proofecho"}[name] + seen := false + for _, tool := range childTools { + seen = seen || tool == want + } + if !seen { + t.Fatalf("the Subagent did not run %s", want) + } + if name == "function" && (len(calls) != 1 || calls[0].NativeID == "" || calls[0].TurnID == "" || !strings.Contains(answer, "FN-4729")) { + t.Fatal("the Subagent call did not reach the client with its child identity") + } + if name == "mcp" && (len(calls) != 0 || !strings.Contains(answer, "MCP-5521")) { + t.Fatal("the MCP value did not reach the root") + } + }) + } +} diff --git a/apps/daemon/internal/agent/codex/trust.go b/apps/daemon/internal/agent/codex/trust.go new file mode 100644 index 000000000..83fa1d697 --- /dev/null +++ b/apps/daemon/internal/agent/codex/trust.go @@ -0,0 +1,28 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" +) + +// writeWorkspaceTrust marks the workspace as a trusted native project, the entry +// thread/start would persist, so start and cold resume load the same workspace +// configuration. The key is the nearest checkout root, else the workspace. +func writeWorkspaceTrust(codexHome, workspace string) error { + root := workspace + for dir := workspace; ; dir = filepath.Dir(dir) { + if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { + root = dir + break + } + if filepath.Dir(dir) == dir { + break + } + } + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + return appendConfigTOML(filepath.Join(codexHome, "config.toml"), + fmt.Sprintf("[projects.%s]\ntrust_level = \"trusted\"\n\n", tomlQuoteString(root))) +} diff --git a/apps/daemon/internal/agent/codex/workspace_trust_native_test.go b/apps/daemon/internal/agent/codex/workspace_trust_native_test.go new file mode 100644 index 000000000..63ffeb1bb --- /dev/null +++ b/apps/daemon/internal/agent/codex/workspace_trust_native_test.go @@ -0,0 +1,135 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// A trusted workspace loads its own native configuration on start and on cold +// resume, but may not move a declared MCP server's credential binding. Opt-in: +// it needs the pinned native binary and no model call. +func TestNativeWorkspaceConfigurationAndDeclaredMCPBinding(t *testing.T) { + binary := os.Getenv("OAC_TEST_CODEX_BINARY") + if binary == "" { + t.Skip("requires the pinned native Codex binary") + } + if version, err := exec.Command(binary, "--version").Output(); err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native artifact is not Codex 0.153.4") + } + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + cfg := defaultSessionConfig(testStateRoot(t)) + cfg.codexBinary = binary + cfg.logger = slog.New(slog.DiscardHandler) + workspace := t.TempDir() + if err := exec.Command("git", "init", "-q", workspace).Run(); err != nil { + t.Fatal(err) + } + writeWorkspace := func(body string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(workspace, ".codex"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, ".codex", "config.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + token := "synthetic-private-token" + prepare := func(ctx context.Context, thread string) (*Executor, error) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "docs", ServerURL: "https://docs.invalid/mcp", BearerToken: &token}} + return newExecutor(ctx, proto.PromptRequestPayload{ + AgentKind: "codex", AgentStateKey: "workspace-trust", DisableSubagents: true, + AgentSessionID: thread, RequireExistingNativeSession: thread != "", MCPHTTPServers: &servers, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceRoot: workspace}, + AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"base_url": "http://127.0.0.1:9/v1", "protocol": "responses", "api_key": "unused"}}, + }, cfg) + } + closeExecutor := func(e *Executor) { + c, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + _ = e.Close(c) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + readConfig := func(e *Executor) (map[string]any, map[string]any) { + t.Helper() + raw, err := e.prepared.session.rpc.Request(ctx, "config/read", map[string]any{"cwd": workspace, "includeLayers": false}) + if err != nil { + t.Fatal(err) + } + var response struct { + Config struct { + Servers map[string]any `json:"mcp_servers"` + Features map[string]any `json:"features"` + } `json:"config"` + } + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatal(err) + } + return response.Config.Servers, response.Config.Features + } + + // Additional workspace servers are admitted and plugins and apps keep their native values. + writeWorkspace("[mcp_servers.extra]\ncommand = \"true\"\n\n[mcp_servers.docs]\ntool_timeout_sec = 7\n") + e, err := prepare(ctx, "") + if err != nil { + t.Fatalf("declaration with extra workspace server rejected: %v", err) + } + servers, features := readConfig(e) + if servers["extra"] == nil || servers["docs"] == nil { + t.Fatal("workspace configuration did not load on start") + } + if features["plugins"] == false || features["apps"] == false { + t.Fatal("plugins or apps were forced off") + } + output := make(chan proto.Envelope, 64) + turn, err := e.StartTurn(ctx, "first", proto.TextInput("Reply OK."), output) + if err != nil { + t.Fatal(err) + } + for done := false; !done; { + select { + case event := <-output: + done = event.Type == proto.TypeDone + case <-ctx.Done(): + t.Fatal("Turn did not settle") + } + } + _, _ = turn.AwaitSettlement(ctx) + thread := turn.(*Session).currentThreadID() + closeExecutor(e) + if thread == "" { + t.Fatal("no native thread to resume") + } + + // A cold resume loads the same workspace configuration. + e, err = prepare(ctx, thread) + if err != nil { + t.Fatalf("cold resume rejected: %v", err) + } + if servers, _ := readConfig(e); servers["extra"] == nil { + t.Fatal("workspace configuration did not load on cold resume") + } + closeExecutor(e) + + // Redirecting the declared server fails start and cold resume before any native Turn. + writeWorkspace("[mcp_servers.docs]\nurl = \"https://workspace.invalid/mcp\"\n") + for _, resume := range []string{"", thread} { + e, err := prepare(ctx, resume) + if !errors.Is(err, errMCPBindingChanged) { + t.Fatalf("redirected declared server admitted (resume=%q): %v", resume, err) + } + if e != nil { + closeExecutor(e) + } + } +} diff --git a/apps/daemon/internal/agent/contract_declarations_test.go b/apps/daemon/internal/agent/contract_declarations_test.go index 7915f9df6..bae330dbc 100644 --- a/apps/daemon/internal/agent/contract_declarations_test.go +++ b/apps/daemon/internal/agent/contract_declarations_test.go @@ -1,11 +1,9 @@ package agent_test import ( - "encoding/json" "go/ast" "go/parser" "go/token" - "os" "path/filepath" "reflect" "strings" @@ -20,7 +18,6 @@ func TestPublicHarnessContractDeclarations(t *testing.T) { "Turn": {"session"}, "Session": {"session"}, "DurableSteerer": {"session"}, - "Steerer": {"session"}, "FunctionResultSubmitter": {"session"}, "WorkspaceReader": {"executor", "session"}, "WorkspaceDirectoryLister": {"executor", "session"}, @@ -58,17 +55,12 @@ func TestPublicHarnessContractDeclarations(t *testing.T) { t.Fatalf("unclassified interface %s", name) } } - data, err := os.ReadFile("../../../../internal/harnessconfig/builtin/catalog.json") - if err != nil { - t.Fatal(err) - } - var catalog []struct { - Configuration string `json:"configuration"` - } - if err := json.Unmarshal(data, &catalog); err != nil { - t.Fatal(err) + declarations, err := filepath.Glob("../../../../internal/harnessconfig/*/declaration.go") + if err != nil || len(declarations) == 0 { + t.Fatal("no Harness declarations", err) } - for _, entry := range catalog { + for _, declaration := range declarations { + entry := struct{ Configuration string }{filepath.Base(filepath.Dir(declaration))} t.Run(entry.Configuration, func(t *testing.T) { f, err := parser.ParseFile(token.NewFileSet(), filepath.Join(entry.Configuration, "contracts.go"), nil, 0) if err != nil { diff --git a/apps/daemon/internal/agent/contracttest/text.go b/apps/daemon/internal/agent/contracttest/text.go index c389163a7..bc7f336a7 100644 --- a/apps/daemon/internal/agent/contracttest/text.go +++ b/apps/daemon/internal/agent/contracttest/text.go @@ -71,7 +71,7 @@ func TextLifecycle(t *testing.T, fixture TextFixture) { t.Fatal("public text Turn must implement DurableSteerer") } var written atomic.Int32 - if err := steerer.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "contract-input", Input: fixture.SteeringInput, DurableReceipt: true}, func() { written.Add(1) }); err != nil { + if err := steerer.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "contract-input", Input: fixture.SteeringInput}, func() { written.Add(1) }); err != nil { t.Fatalf("active input after stale cancellation: %v", err) } if written.Load() != 1 { @@ -168,3 +168,12 @@ func settled(t *testing.T, ctx context.Context, turn agent.Turn, output *capture func sameTurn(a, b agent.Turn) bool { return reflect.ValueOf(a).Comparable() && a == b } + +// RequireNarrowed fails when discovery grants a capability its declaration does +// not make: discovery may only withdraw support the installed host cannot give. +func RequireNarrowed(t *testing.T, declared, discovered proto.AgentKindCapabilities) { + t.Helper() + if widened := agent.Widened(declared, discovered); len(widened) > 0 { + t.Errorf("discovery widened %v", widened) + } +} diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 4804282de..19f2364df 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -9,38 +9,70 @@ // return ErrUnsupportedOperation before any native effects. Interface presence // does not advertise support; the capability declaration controls admission. MCP, // images, structured output and Subagent observations use protocol messages -// rather than additional Go interfaces; qualify and advertise them separately. +// rather than additional Go interfaces; declare and advertise them separately. // -// 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 factories, so Register installs it first. RegisterExecutor derives the -// Preparation capability; the adapter declares WorkspaceReadPreparation. +// Registration: each adapter exports one Declaration, which binds its shared +// declaration (internal/harnessconfig//declaration.go) to Discover. +// The Runtime discovers the generated declaration list and installs each +// resulting Runtime through Register. Availability and factory selection belong +// to the adapter. RegisterKind resets the factories, so Register installs it +// first. RegisterExecutor derives the Preparation capability. // -// Runtime registration and Core service qualification remain separate. A public -// Harness also needs a profile in services/core/internal/engine; advertising -// a capability cannot authorize it. Requests, events and capability descriptors -// use the existing internal/agentdaemon/proto types. An Environment execution -// request carries the Runtime's bound workspace directory in -// LocalEnvironment.WorkspaceRoot; the native Harness runs there. +// The shared declaration is the only authored statement of what a Harness +// supports; Core admission reads the same value. Discovery may only narrow its +// capabilities for the installed host, with Narrow. Requests, events and +// capability descriptors use the existing internal/agentdaemon/proto types. An +// Environment execution request carries the Runtime's bound workspace directory +// in LocalEnvironment.WorkspaceRoot; the native Harness runs there. package agent import ( "context" "io" + "reflect" + "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) -// Declaration is the complete startup contract for a Harness implementation. +// Declaration binds a Harness's shared declaration to its native discovery. // Discover returns nil when the adapter is not configured. An unavailable // configured adapter returns a Runtime with Available=false and no factories. -// Discovery owns runtime-specific configuration, readiness and feature gates. +// Discovery owns runtime-specific configuration and readiness, and narrows the +// declared capabilities to what the host provides; it never widens them. type Declaration struct { - Info proto.SupportedAgentKind - Configuration harnessconfig.Configuration - Discover func(context.Context, DiscoveryOptions, proto.SupportedAgentKind) *Runtime + harnessconfig.Declaration + Discover func(context.Context, DiscoveryOptions, proto.SupportedAgentKind) *Runtime +} + +// Unavailable is the Runtime of a configured adapter the host cannot run. It +// declares nothing supported and has no factories. +func Unavailable(info proto.SupportedAgentKind) *Runtime { + info.Available, info.Version = false, "" + info.Capabilities = proto.UnsupportedCapabilities() + return &Runtime{Info: info} +} + +// Widened names the capabilities discovery reports supported although the +// declaration does not. +func Widened(declared, discovered proto.AgentKindCapabilities) []string { + d, found := reflect.ValueOf(declared), reflect.ValueOf(discovered) + var widened []string + for i := 0; i < d.NumField(); i++ { + if found.Field(i).Interface() == proto.CapabilitySupported && d.Field(i).Interface() != proto.CapabilitySupported { + widened = append(widened, d.Type().Field(i).Name) + } + } + return widened +} + +// Narrow withdraws a declared capability the host cannot provide. It never +// grants one. +func Narrow(capability *proto.CapabilitySupport, provided bool) { + if !provided { + *capability = proto.CapabilityUnsupported + } } // DiscoveryOptions provides process context without naming an implementation. @@ -61,12 +93,17 @@ type Runtime struct { // Register installs a discovered Runtime with its declaration's configuration. func (r *Registry) Register(declaration Declaration, runtime Runtime) { - if runtime.Info.Kind != declaration.Info.Kind { + if runtime.Info.Kind != declaration.Kind { panic("agent.Registry.Register: discovery kind differs from declaration") } if !runtime.Info.Available && runtime.Executor != nil { panic("agent.Registry.Register: unavailable runtime has factories") } + advertised := runtime.Info.Capabilities + advertised.Preparation = proto.CapabilityFromBool(runtime.Executor != nil) + if widened := Widened(declaration.Capabilities, advertised); len(widened) > 0 { + panic("agent.Registry.Register: discovery widened " + strings.Join(widened, ", ")) + } r.RegisterKind(runtime.Info, declaration.Configuration) if runtime.Executor != nil { r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) @@ -80,8 +117,8 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { // The declaration belongs to the adapter and is also consumed by Core. Keep // adapter field rules and rendering private. That shared contract owns frozen // configuration and native application obligations. This file owns execution -// lifecycle only. Native image/tool/operation support is qualified through proto -// capabilities and the Core engine profile, not model configuration declarations. +// lifecycle only. Native image/tool/operation support is declared through the +// declaration's capabilities, not model configuration. // // Preparation failure retains unconfirmed native cleanup in a non-nil Executor // under the factory ownership contract below. @@ -139,18 +176,12 @@ type Session interface { // unsupported operations return ErrUnsupportedOperation with a fixed safe reason. // DurableSteerer reports one complete write synchronously, then waits for the native receipt. -// It is independent of Steerer and is mandatory on every public Turn. +// It is mandatory on every public Turn. // Required input receipts cannot be implemented by returning Unsupported. type DurableSteerer interface { SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error } -// Steerer delivers non-durable input when qualified; otherwise it explicitly -// returns ErrUnsupportedOperation without submitting input. -type Steerer interface { - Steer(context.Context, proto.PromptSteerPayload) error -} - // FunctionResultSubmitter delivers a result for an outstanding native call. // A successful return requires its native application receipt. type FunctionResultSubmitter interface { diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go index 3f01ff20b..30e32fa6f 100644 --- a/apps/daemon/internal/agent/mcode/contracts.go +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -9,7 +9,6 @@ var ( _ agent.Turn = (*Session)(nil) _ agent.Session = (*Session)(nil) _ agent.DurableSteerer = (*Session)(nil) - _ agent.Steerer = (*Session)(nil) _ agent.FunctionResultSubmitter = (*Session)(nil) _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 03bbef288..4da7072f7 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -7,41 +7,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) -// Declaration owns MiniMax Code discovery, configuration and execution factories. -var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilityUnsupported, - Resume: proto.CapabilitySupported, - RetainedNativeHistory: proto.CapabilityUnsupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, -}}, Configuration: configuration.Configuration(), Discover: discover} +// Declaration binds the shared MiniMax Code declaration to discovery and execution factories. +var Declaration = agent.Declaration{Declaration: declaration.Declaration, Discover: discover} func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { return discoverWithCheck(ctx, options, info, CheckCLIAvailable) @@ -54,21 +24,9 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r version, err := check(ctx, "") if err != nil { fmt.Fprintf(options.Stderr, "oac-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) - return runtime + return agent.Unavailable(result) } result.Available, result.Version = true, 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 runtime.Info = result workspace := discoverWorkspace(parent, options, runtime) if runtime.Info.Available { diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index 386c4df2d..44d6ffba5 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -4,11 +4,10 @@ import ( "context" "errors" "io" - "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/contracttest" ) // An available runtime is execution-capable; a rejected native version is unavailable. @@ -19,7 +18,7 @@ func TestMCodeExecutionFollowsAvailability(t *testing.T) { }{{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, tc.check }) + 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) @@ -27,28 +26,15 @@ func TestMCodeExecutionFollowsAvailability(t *testing.T) { 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() { + if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() { t.Fatal("unqualified capability advertised") } }) } } -// The declaration must retain the complete baseline capability descriptor. -func TestDeclaredCapabilityBaseline(t *testing.T) { - 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 - want := proto.CapabilityUnsupported - if expected[name] { - want = proto.CapabilitySupported - } - if got := value.Field(i).Interface(); got != want { - t.Errorf("%s = %v, want %v", name, got, want) - } - } - if err := Declaration.Info.ValidateDeclaration(); err != nil { - t.Fatal(err) - } +// Discovery narrows the declaration for the installed host and never widens it. +func TestDiscoveryOnlyNarrowsDeclaration(t *testing.T) { + runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info(), func(context.Context, string) (string, error) { return SupportedVersion, nil }) + contracttest.RequireNarrowed(t, Declaration.Capabilities, runtime.Info.Capabilities) } diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go index aa3461192..d8f60f021 100644 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -15,7 +15,7 @@ import ( func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, runtime *agent.Runtime) *WorkspaceConfig { fail := func(err error) { - runtime.Info.Available = false + *runtime = *agent.Unavailable(runtime.Info) fmt.Fprintf(options.Stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) } binding, err := localworkspace.Load() @@ -24,6 +24,7 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r return nil } if binding == nil { + narrowWorkspace(&runtime.Info.Capabilities, false) return nil } node := os.Getenv("OAC_RUNTIME_MCODE_NODE") @@ -50,7 +51,7 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r fail(err) return nil } - c, err := ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), options.RuntimeRoot, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) + c, err := ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_COMPANION"), os.Getenv("OAC_RUNTIME_WORKSPACE")) if err == nil { err = CheckWorkspace(parent, c) } @@ -59,8 +60,14 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r return nil } - caps := &runtime.Info.Capabilities - caps.EnvironmentNone = proto.CapabilityUnsupported - caps.LocalEnvironment, caps.WorkspaceReadPreparation = proto.CapabilitySupported, proto.CapabilitySupported + narrowWorkspace(&runtime.Info.Capabilities, true) return &c } + +// narrowWorkspace keeps either the local workspace capabilities or environment:none. +func narrowWorkspace(caps *proto.AgentKindCapabilities, local bool) { + agent.Narrow(&caps.EnvironmentNone, !local) + agent.Narrow(&caps.LocalEnvironment, local) + agent.Narrow(&caps.WorkspaceReadPreparation, local) + agent.Narrow(&caps.WorkspaceOutputExport, local) +} diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index c675465c8..310d0cfc7 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -15,37 +15,38 @@ func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, error) { if err != nil { return nil, err } - var servers []map[string]any + servers := []map[string]any{} for _, binding := range bindings { - if binding.ServerLabel == "oac_workspace" || binding.ConnectionOrigin != "environment" || binding.AllowedTools != nil || binding.Required { - return nil, fmt.Errorf("mcode: unsupported MCP binding") - } if binding.Transport == "http" { - server, err := environmentHTTPMCP(binding) + server, err := httpMCP(binding) if err != nil { return nil, err } servers = append(servers, server) - } else { + } else if binding.Stdio != nil { command, args := localworkspace.MCPStdioCommand(*binding.Stdio) servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}) + } else { + return nil, fmt.Errorf("mcode: public MCP stdio servers are unsupported") } } return servers, nil } // ACP session servers remain in native memory; credentials never enter argv or -// persisted native configuration. Custom headers are not qualified because the -// pinned native HTTP client can forward them across redirect origins. -func environmentHTTPMCP(item agent.MCPBinding) (map[string]any, error) { +// persisted native configuration. +func httpMCP(item agent.MCPBinding) (map[string]any, error) { endpoint, err := url.Parse(item.ServerURL) - if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Opaque != "" || len(item.HTTPHeaders) != 0 { - return nil, fmt.Errorf("mcode: unsupported environment HTTP MCP declaration") + if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" { + return nil, fmt.Errorf("mcode: unsupported HTTP MCP declaration") } headers := []map[string]string{} + for name, value := range item.HTTPHeaders { + headers = append(headers, map[string]string{"name": name, "value": value}) + } if item.BearerToken != nil { if endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*item.BearerToken) { - return nil, fmt.Errorf("mcode: unsupported environment MCP bearer credential") + return nil, fmt.Errorf("mcode: unsupported MCP bearer credential") } headers = append(headers, map[string]string{"name": "Authorization", "value": "Bearer " + *item.BearerToken}) } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index dae9ac41f..677d6ced2 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -25,7 +25,6 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { for _, resume := range []bool{false, true} { t.Run(map[bool]string{false: "new", true: "load"}[resume], func(t *testing.T) { c, req, record := workspaceFixture(t) - c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} if resume { req.AgentSessionID = "native-1" @@ -50,15 +49,15 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { Env []map[string]string } `json:"mcpServers"` } - if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 2 || params.MCP[0].Name != "oac_workspace" { - t.Fatal("environment MCP displaced workspace tools") + if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 1 { + t.Fatal("native Session did not receive exactly the declared MCP server") } cwd, err := os.ReadFile(record + ".cwd") workspace, pathErr := filepath.EvalSymlinks(req.LocalEnvironment.WorkspaceRoot) if err != nil || pathErr != nil || string(cwd) != workspace || params.Cwd != req.LocalEnvironment.WorkspaceRoot { t.Fatalf("native process and ACP Session must use the declared workspace: process=%q ACP=%q", cwd, params.Cwd) } - server := params.MCP[1] + server := params.MCP[0] executable, _ := os.Executable() if server.Name != "proof.server" || server.Command != executable || server.Env == nil || len(server.Env) != 0 || !reflect.DeepEqual(server.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/fixture", "proof.server"}) { @@ -72,17 +71,13 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { } func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T) { - for _, name := range []string{"http-headers", "http-bearer-insecure", "http-bearer-missing", "restricted", "disabled", "duplicate", "reserved"} { + for _, name := range []string{"http-bearer-insecure", "http-bearer-missing", "duplicate"} { t.Run(name, func(t *testing.T) { c, req, _ := workspaceFixture(t) - c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} switch name { - case "http-headers", "http-bearer-insecure", "http-bearer-missing": + case "http-bearer-insecure", "http-bearer-missing": req.LocalEnvironment.MCP[0].Server = agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"} - if name == "http-headers" { - req.LocalEnvironment.MCP[0].Server.HTTPHeaders = map[string]string{"X-Private": "secret"} - } if name == "http-bearer-missing" { req.LocalEnvironment.MCP[0].Server.BearerTokenEnvVar = "SELECTED_TOKEN" } @@ -91,23 +86,23 @@ func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T token := "confidential-http-token" req.LocalEnvironment.MCP[0].BearerToken = &token } - case "restricted", "disabled": - c.Network, req.LocalEnvironment.NetworkAccess = name, name case "duplicate": req.LocalEnvironment.MCP = append(req.LocalEnvironment.MCP, environmentMCPFixture()) - case "reserved": - req.LocalEnvironment.MCP[0].Server.Name = "oac_workspace" } - if _, err := prepareWorkspaceOptions(testStateRoot(t), c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { + root := t.TempDir() + _, err := NewExecutorFactory(root, &c)(context.Background(), req) + if err == nil || strings.Contains(err.Error(), "confidential-http-token") { t.Fatal("unqualified declaration accepted or credential exposed") } + if entries, _ := os.ReadDir(root); len(entries) != 0 { + t.Fatalf("native files written before MCP was rejected: %v", entries) + } }) } } func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { c, req, _ := workspaceFixture(t) - c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} script, err := os.ReadFile(c.Binary) if err != nil { @@ -182,7 +177,6 @@ func mcpRegistryEntry(server, segment, tool, toolSegment string) map[string]any func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { for _, authenticated := range []bool{false, true} { c, req, record := workspaceFixture(t) - c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" item := proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}} const token = "private-mcp-canary" if authenticated { @@ -205,10 +199,10 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { Headers []map[string]string } `json:"mcpServers"` } - if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 2 { - t.Fatal("HTTP MCP displaced workspace tools") + if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 1 { + t.Fatal("native Session did not receive exactly the declared MCP server") } - server := params.MCP[1] + server := params.MCP[0] if server.Name != "remote" || server.Type != "http" || server.URL != item.Server.URL || server.Headers == nil { t.Fatal("invalid native HTTP projection") } @@ -220,28 +214,29 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { t.Fatal("anonymous MCP inherited credentials") } 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) { - t.Fatal(err) - } - if strings.Contains(string(body), token) { - t.Fatal("MCP credential persisted in native configuration") - } + body, err := os.ReadFile(filepath.Join(dataDir, "config.yaml")) + if err != nil || strings.Contains(string(body), token) { + t.Fatal("MCP credential persisted in native configuration", err) + } + if _, err := os.Stat(filepath.Join(dataDir, "mcp.json")); !os.IsNotExist(err) { + t.Fatal("adapter wrote a native MCP file") } } } func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { c, req, _ := workspaceFixture(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(testStateRoot(t), c, req) if err != nil { t.Fatal(err) } - raw, err := json.Marshal(opts.MCP) + servers, err := runtimeMCP(req) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(servers) if err != nil || !strings.Contains(string(raw), "Bearer "+token) { t.Fatal("selected token not supplied to native ACP") } @@ -264,14 +259,31 @@ func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { if err != nil { t.Fatal(err) } - empty := []string{} - (*req.MCPHTTPServers)[0].AllowedTools = &empty - if _, err := prepareWorkspaceOptions(testStateRoot(t), c, req); err == nil { - t.Fatal("empty allowlist silently treated as all") +} + +func TestMCPHeadersReachACPHeaders(t *testing.T) { + req := testRequest(t) + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://mcp.example/mcp", Headers: map[string]string{"X-Tenant": "a"}}} + servers, err := runtimeMCP(req) + want := []map[string]string{{"name": "X-Tenant", "value": "a"}} + if err != nil || len(servers) != 1 || !reflect.DeepEqual(servers[0]["headers"], want) { + t.Fatalf("MCP = %v, %v", servers, err) + } +} + +func TestServiceOriginMCPOnNonePlacement(t *testing.T) { + req := testRequest(t) + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://mcp.example/mcp"}} + servers, err := runtimeMCP(req) + want := []map[string]any{{"name": "fixture", "type": "http", "url": "https://mcp.example/mcp", "headers": []map[string]string{}}} + if err != nil || !reflect.DeepEqual(servers, want) { + t.Fatalf("MCP = %v, %v", servers, err) } - (*req.MCPHTTPServers)[0].AllowedTools = nil - (*req.MCPHTTPServers)[0].Required = true - if _, err := prepareWorkspaceOptions(testStateRoot(t), c, req); err == nil { - t.Fatal("required initialization silently ignored") +} + +func TestPublicMCPStdioIsRejected(t *testing.T) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", Stdio: &proto.MCPStdio{Command: "run", Cwd: "/work"}}} + if _, err := runtimeMCP(proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers}); err == nil { + t.Fatal("native starts session stdio servers outside the declared cwd") } } diff --git a/apps/daemon/internal/agent/mcode/events.go b/apps/daemon/internal/agent/mcode/events.go index a41789fd1..f9c562e01 100644 --- a/apps/daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -37,18 +37,22 @@ func (s *Session) handle(frame rpcFrame) error { s.mu.Lock() s.steeringReady = true s.mu.Unlock() + var chunk struct { + Type string `json:"type"` + Text string `json:"text"` + } + if kind := event.Update.Kind; kind == "agent_message_chunk" && json.Unmarshal(event.Update.Content, &chunk) != nil { + return fmt.Errorf("mcode: invalid session update") + } switch event.Update.Kind { case "agent_message_chunk": - if event.Update.Content.Type != "text" { + if chunk.Type != "text" { return fmt.Errorf("mcode: unsupported response content") } - text := event.Update.Content.Text + text := chunk.Text s.content.WriteString(text) s.sequence++ s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: s.sequence}) - case "agent_thought_chunk": - s.sequence++ - s.emit(proto.TypeThinking, proto.ThinkingPayload{Text: event.Update.Content.Text, Sequence: s.sequence}) case "tool_call", "tool_call_update": return s.emitTool(event.Update.toolUpdate) } @@ -75,12 +79,12 @@ func (s *Session) emitTool(update toolUpdate) error { } if update.mcp == nil { var err error - update.mcp, err = s.environmentMCPIdentity(update.Name) + update.mcp, err = s.mcpIdentity(update.Name) if err != nil { return err } } - if previous.mcp == nil && workspaceToolObservation(previous, "before") == nil { + if previous.mcp == nil && s.nativeToolObservation(previous, "before") == nil { if err := s.emitToolStage(update, "before"); err != nil { return err } diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index e35d927a2..729d0a3d8 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -3,17 +3,22 @@ package mcode import ( "fmt" "os" + "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) 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) { + if !req.DisableExecutionEnvironment || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession { return fmt.Errorf("mcode: unsupported execution configuration") } - if req.ExecutionControls == nil || req.ExecutionControls.OutputFormat != nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { + if req.ExecutionControls == nil || req.ExecutionControls.OutputFormat != nil { return fmt.Errorf("mcode: unsupported execution controls") } + if err := declaration.Declaration.ValidateExecutionControls(req.ExecutionControls); err != nil { + return err + } if !req.DisableSubagents { if req.MaxConcurrentSubagents == nil || *req.MaxConcurrentSubagents < 1 { return fmt.Errorf("mcode: Subagent concurrency limit is required") @@ -25,20 +30,16 @@ func validateExecutionRequest(req proto.PromptRequestPayload) error { return nil } -func configureTextExecution(config map[string]any) { - config["agents"] = map[string]any{"default": map[string]any{ - "tools": []string{}, "builtinTools": []string{}, "skills": []string{}, - "features": map[string]bool{"mavis": false, "delegation": false, "webSearch": false}, - }} - config["askUser"] = map[string]bool{"enabled": false} - config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} -} - // 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. A second, empty -// block keeps public input as user text. +// ACP joins text blocks with "\n\n" and parses slash commands only from a +// single block. A second, empty block keeps input that starts with "/" as user +// text; any other input is sent as one block so the model sees it unchanged. func promptContent(text string) []map[string]string { - return []map[string]string{{"type": "text", "text": text}, {"type": "text", "text": ""}} + content := []map[string]string{{"type": "text", "text": text}} + if strings.HasPrefix(text, "/") { + content = append(content, map[string]string{"type": "text", "text": ""}) + } + return content } diff --git a/apps/daemon/internal/agent/mcode/execution_test.go b/apps/daemon/internal/agent/mcode/execution_test.go index afa6b0ad1..df1b2841b 100644 --- a/apps/daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -14,7 +14,7 @@ func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { r := testRequest(t) t.Setenv("OAC_TEST_SECRET_CANARY", "secret") t.Setenv("NODE_OPTIONS", "--import=untrusted") - opts, err := prepareOptions(testStateRoot(t), r) + opts, err := prepareOptions(testStateRoot(t), r, nil) if err != nil { t.Fatal(err) } @@ -43,21 +43,22 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = false }, func(r *proto.PromptRequestPayload) { r.DisableSubagents = false }, 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.ExecutionControls.WebSearch = &proto.WebSearchControl{Mode: "live", ContextSize: "medium"} + }, } { r := testRequest(t) change(&r) - if _, err := prepareOptions(testStateRoot(t), r); err == nil { + if _, err := prepareOptions(testStateRoot(t), r, nil); err == nil { t.Fatal("unsupported execution accepted") } } } func TestPublicTextDoesNotInvokeACPCommands(t *testing.T) { - for _, text := range []string{"/model", "/compact", "hello"} { + for text, want := range map[string]int{"/model": 2, "/compact": 2, "hello": 1} { blocks := promptContent(text) - if len(blocks) != 2 || blocks[0]["text"] != text || blocks[1]["text"] != "" { + if len(blocks) != want || blocks[0]["text"] != text || (want == 2 && blocks[1]["text"] != "") { t.Fatal(blocks) } } diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 0ea08bf3b..9853f9d5a 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "slices" "strings" "sync" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) var errTurnCancelled = errors.New("mcode: Turn cancelled before submission") @@ -32,7 +34,6 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu var frozen *WorkspaceConfig if config != nil { value := *config - value.AllowedDomains = append([]string(nil), config.AllowedDomains...) frozen = &value } return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { @@ -42,11 +43,15 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu if req.RunID != "" || len(req.Input) != 0 { return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") } - var err error + // MCP is checked before any native file is written. + mcp, err := runtimeMCP(req) + if err != nil { + return nil, err + } var opts launchOptions binary := defaultBinary() if frozen == nil { - opts, err = prepareOptions(runtimeRoot, req) + opts, err = prepareOptions(runtimeRoot, req, nil) } else { binary = frozen.Binary opts, err = prepareWorkspaceOptions(runtimeRoot, *frozen, req) @@ -54,6 +59,16 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu if err != nil { return nil, err } + opts.MCP = mcp + if len(req.FunctionTools) > 0 && slices.ContainsFunc(mcp, func(server map[string]any) bool { return server["name"] == declaration.FunctionServerLabel }) { + return nil, fmt.Errorf("mcode: a Plugin MCP server uses the reserved label %s", declaration.FunctionServerLabel) + } + if opts.Functions, err = serveFunctions(req.FunctionTools); err != nil { + return nil, err + } + if opts.Functions != nil { + opts.MCP = append(opts.MCP, opts.Functions.mcpServer()) + } resource, err := newExecutor(ctx, req, opts, binary) if resource == nil { return nil, err @@ -65,9 +80,13 @@ func NewExecutorFactory(runtimeRoot string, config *WorkspaceConfig) agent.Execu func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { bootstrap, err := launch(ctx, req, opts, binary) if err != nil { + opts.Functions.close() return nil, err } e := &executor{connection: bootstrap.connection, req: req, opts: opts} + if opts.Functions != nil { + opts.Functions.owner.Store(e) + } err = bootstrap.prepareNative() e.connection.setCurrent(nil) close(bootstrap.finished) @@ -160,6 +179,7 @@ func (e *executor) Close(ctx context.Context) error { } e.connection.process.Cancel() e.mu.Unlock() + e.opts.Functions.close() select { case <-e.connection.exited: case <-ctx.Done(): diff --git a/apps/daemon/internal/agent/mcode/executor_backpressure_test.go b/apps/daemon/internal/agent/mcode/executor_backpressure_test.go index 4651d46db..00bddf925 100644 --- a/apps/daemon/internal/agent/mcode/executor_backpressure_test.go +++ b/apps/daemon/internal/agent/mcode/executor_backpressure_test.go @@ -78,7 +78,7 @@ func TestExecutorUnknownSteeringOutcomeCannotBecomeAppliedCancellation(t *testin t.Fatal("native prompt did not start") } session := turn.(*Session) - if err := session.Steer(ctx, proto.PromptSteerPayload{InputID: "input-unknown", Input: proto.TextInput("continue")}); err == nil { + if err := session.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input-unknown", Input: proto.TextInput("continue")}, func() {}); err == nil { t.Fatal("unknown native receipt was accepted") } if err := turn.Cancel(ctx); err == nil { diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index d9936a712..c304d5bf4 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -18,7 +18,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload r := testRequest(t) r.RunID, r.Input = "", nil r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: t.TempDir()} record := filepath.Join(t.TempDir(), "calls") exe, err := os.Executable() if err != nil { @@ -30,7 +30,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload 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 + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Companion: "/opt/mcode-harness", Directory: r.LocalEnvironment.WorkspaceRoot}, r, record } func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { @@ -143,15 +143,15 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. if !disabled { // A terminal native history record still cannot prove Bash cleanup. dir := t.TempDir() - node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + node := filepath.Join(dir, "node") body := "#!/bin/sh\nprintf '%s\\n' '{\"version\":1,\"complete\":true,\"rootSessionId\":\"native-1\",\"sessions\":[{\"id\":\"native-1\",\"turns\":[{\"id\":\"root-turn\",\"status\":\"aborted\"}]}]}'\n" - for name, data := range map[string]string{node: body, bridge: "", filepath.Join(dir, "subagent-snapshot.mjs"): ""} { + for name, data := range map[string]string{node: body, filepath.Join(dir, "subagent-snapshot.mjs"): ""} { if err := os.WriteFile(name, []byte(data), 0700); err != nil { t.Fatal(err) } } t.Setenv("OAC_RUNTIME_MCODE_NODE", node) - t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + t.Setenv("OAC_RUNTIME_MCODE_COMPANION", dir) } out := make(chan proto.Envelope, 32) second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) diff --git a/apps/daemon/internal/agent/mcode/executor_turn.go b/apps/daemon/internal/agent/mcode/executor_turn.go index dc24cd17a..a34e18d5a 100644 --- a/apps/daemon/internal/agent/mcode/executor_turn.go +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -34,6 +34,7 @@ func (s *Session) runExecutorTurn(prompt string) { if len(s.tools) > 0 { reusable = false } + s.stopFunctions() s.finishEnvironmentMCP() s.mu.Lock() // ACP and native history cancellation do not prove detached tool cleanup. @@ -121,6 +122,7 @@ func (s *Session) Cancel(ctx context.Context) error { // context. After native settlement, cancellation retires and drains the owner. s.outputCancel() e.mu.Unlock() + s.stopFunctions() var err error if first { raw, _ := json.Marshal(map[string]string{"sessionId": s.sessionID}) diff --git a/apps/daemon/internal/agent/mcode/functions.go b/apps/daemon/internal/agent/mcode/functions.go new file mode 100644 index 000000000..32e88391d --- /dev/null +++ b/apps/daemon/internal/agent/mcode/functions.go @@ -0,0 +1,260 @@ +package mcode + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/json" + "errors" + "net" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" +) + +// functionServer serves the Session's public functions as native MCP tools. It +// listens on loopback only and requires a per-Session bearer token, so only the +// native process that received the ACP server entry can call it. +type functionServer struct { + token string + url string + http *http.Server + done chan struct{} + owner atomic.Pointer[executor] +} + +func serveFunctions(tools []proto.FunctionTool) (*functionServer, error) { + if len(tools) == 0 { + return nil, nil + } + f := &functionServer{token: rand.Text(), done: make(chan struct{})} + server, err := f.newServer(tools) + if err != nil { + return nil, err + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + f.url = "http://" + listener.Addr().String() + "/mcp" + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + want := []byte("Bearer " + f.token) + f.http = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + handler.ServeHTTP(w, r) + })} + go func() { + defer close(f.done) + _ = f.http.Serve(listener) + }() + return f, nil +} + +// newServer registers the tools before any listener exists. AddTool panics on a +// schema it rejects, which becomes an error here. +func (f *functionServer) newServer(tools []proto.FunctionTool) (server *mcp.Server, err error) { + defer func() { + if recover() != nil { + server, err = nil, errors.New("mcode: the MCP server rejected a function schema") + } + }() + server = mcp.NewServer(&mcp.Implementation{Name: declaration.FunctionServerLabel, Version: "1"}, nil) + for _, tool := range tools { + server.AddTool(&mcp.Tool{Name: tool.Name, Description: tool.Description, InputSchema: tool.Parameters}, f.call) + } + return server, nil +} + +// mcpServer is the ACP mcpServers entry; the token stays in transient ACP headers. +func (f *functionServer) mcpServer() map[string]any { + return map[string]any{"name": declaration.FunctionServerLabel, "type": "http", "url": f.url, + "headers": []map[string]string{{"name": "Authorization", "value": "Bearer " + f.token}}} +} + +// close stops the listener and every connection, then waits for the serving goroutine. +func (f *functionServer) close() { + if f == nil { + return + } + _ = f.http.Close() + <-f.done +} + +// call runs on the HTTP handler goroutine; the active Turn owns the call. +func (f *functionServer) call(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + e := f.owner.Load() + if e == nil { + return nil, agent.ErrUnknownFunctionCall + } + e.mu.Lock() + s := e.active + e.mu.Unlock() + if s == nil { + return nil, agent.ErrUnknownFunctionCall + } + return s.callFunction(ctx, req.Params.Name, req.Params.Arguments) +} + +type pendingFunction struct { + call proto.FunctionCallPayload + // settled closes when the result is delivered or the call is released; a nil result means released. + settled chan struct{} + receipt chan error + result *proto.FunctionResultPayload + content []mcp.Content +} + +// functionCalls is one Turn's pending calls. emit holds the lock so a stopped +// Turn never writes to its closed output. +type functionCalls struct { + mu sync.Mutex + pending map[string]*pendingFunction + stopped chan struct{} + closed bool +} + +// functionCallMeta is the CallToolResult _meta key that native echoes in its tool update. +const functionCallMeta = "oac_call_id" + +func (s *Session) callFunction(ctx context.Context, name string, arguments json.RawMessage) (*mcp.CallToolResult, error) { + id := "call_" + rand.Text() + p := &pendingFunction{call: proto.FunctionCallPayload{CallID: id, Name: name, Arguments: arguments}, settled: make(chan struct{}), receipt: make(chan error, 1)} + s.functions.mu.Lock() + if s.functions.closed { + s.functions.mu.Unlock() + return nil, agent.ErrUnknownFunctionCall + } + s.functions.pending[id] = p + s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: name, Stage: "before", Observation: &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: name, Arguments: arguments}}) + s.emit(proto.TypeFunctionCall, p.call) + s.functions.mu.Unlock() + select { + case <-p.settled: + if p.result == nil { + return nil, agent.ErrUnknownFunctionCall + } + return &mcp.CallToolResult{Content: p.content, IsError: !p.result.Success, Meta: mcp.Meta{functionCallMeta: id}}, nil + case <-s.functions.stopped: + return nil, agent.ErrUnknownFunctionCall + case <-ctx.Done(): + s.functions.mu.Lock() + s.release(p) + s.functions.mu.Unlock() + return nil, ctx.Err() + } +} + +// release drops a call that native ended without confirming its result, +// withdraws it from a live Turn and unblocks its waiter. The caller holds +// s.functions.mu. +func (s *Session) release(p *pendingFunction) { + if s.functions.pending[p.call.CallID] != p { + return + } + delete(s.functions.pending, p.call.CallID) + if !s.functions.closed { + s.emit(proto.TypeFunctionCallWithdrawn, proto.FunctionCallWithdrawnPayload{CallID: p.call.CallID}) + } + if p.result == nil { + close(p.settled) + } + p.receipt <- agent.ErrUnknownFunctionCall +} + +func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if err := result.ValidateContent(); err != nil { + return err + } + content := make([]mcp.Content, 0, len(result.Content)) + for _, part := range result.Content { + if part.Text == nil { + return errors.New("mcode: function result images are not supported") + } + content = append(content, &mcp.TextContent{Text: *part.Text}) + } + s.functions.mu.Lock() + p := s.functions.pending[result.CallID] + if s.functions.closed || p == nil || p.result != nil { + s.functions.mu.Unlock() + return agent.ErrUnknownFunctionCall + } + p.result, p.content = &result, content + close(p.settled) + s.functions.mu.Unlock() + // The receive loop calls this inline, so the wait is bounded. + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + select { + case err := <-p.receipt: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// confirmFunction applies a terminal native update for a function tool. Native +// echoes the returned result, with the call ID in its _meta, only when it +// received one; any other end means native gave up on the call, such as its +// 120 s timeout. +// Native runs MCP tools one at a time, so at most one call is in flight. +func (s *Session) confirmFunction(update toolUpdate) { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + var echo struct { + Details struct { + MCP struct { + Meta map[string]any `json:"_meta"` + } `json:"mcp"` + } `json:"details"` + } + raw, _ := json.Marshal(update.RawOutput) + _ = json.Unmarshal(raw, &echo) + id, _ := echo.Details.MCP.Meta[functionCallMeta].(string) + p := s.functions.pending[id] + if p == nil { + for _, q := range s.functions.pending { + if q.call.Name == update.mcp.tool { + p = q + } + } + } + if p == nil { + return + } + if id != p.call.CallID || p.result == nil { + s.release(p) + return + } + delete(s.functions.pending, id) + status := "completed" + if update.Status != "completed" || !p.result.Success { + status = "failed" + } + s.emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: p.call.Name, Stage: "after", Observation: &proto.ToolObservation{Kind: "function", Status: status, Name: p.call.Name, Arguments: p.call.Arguments, Content: &p.result.Content}}) + p.receipt <- nil +} + +// stopFunctions ends the Turn's calls: native gets an error for each pending +// call and each unconfirmed submission fails. +func (s *Session) stopFunctions() { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + if s.functions.closed { + return + } + s.functions.closed = true + close(s.functions.stopped) + for _, p := range s.functions.pending { + s.release(p) + } +} diff --git a/apps/daemon/internal/agent/mcode/functions_native_test.go b/apps/daemon/internal/agent/mcode/functions_native_test.go new file mode 100644 index 000000000..aa8cbaafb --- /dev/null +++ b/apps/daemon/internal/agent/mcode/functions_native_test.go @@ -0,0 +1,78 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// This opt-in test makes real model calls: the model calls a public function, +// native receives the submitted result, and the Turn finishes with it. +func TestNativeMCodePublicFunction(t *testing.T) { + binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if binary == "" || options == "" { + t.Skip("native executable and private provider options required") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal("private provider options unavailable") + } + req := testRequest(t) + req.RunID, req.Input = "", nil + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private provider options") + } + req.FunctionTools = []proto.FunctionTool{{Name: "lookup_code", Description: "Returns the secret access code. Always call it when asked for the access code.", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}} + e, err := prepareExecutor(t, ctx, req) + if err != nil { + t.Fatal("native Executor preparation failed") + } + out := make(chan proto.Envelope, 256) + turn, err := e.StartTurn(ctx, "turn", proto.TextInput("Call the lookup_code function to get the access code, then reply with only the code."), out) + if err != nil { + t.Fatal("native Turn admission failed") + } + var content string + submitted, confirmed := make(chan error, 1), false + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("native Turn reported an error: %s", event.Payload) + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + _ = json.Unmarshal(event.Payload, &call) + text := "ACCESS-CODE-4417" + go func() { + submitted <- turn.(*Session).SubmitFunctionResult(ctx, proto.FunctionResultPayload{DeliveryID: "delivery", CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}) + }() + case proto.TypeToolCall: + var call proto.ToolCallPayload + confirmed = confirmed || (json.Unmarshal(event.Payload, &call) == nil && call.Stage == "after" && call.Observation != nil && call.Observation.Kind == "function" && call.Observation.Status == "completed") + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(event.Payload, &done) + content = done.Content + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal("native Turn did not settle") + } + select { + case err := <-submitted: + if err != nil { + t.Fatal("function result was not applied:", err) + } + default: + t.Fatal("the model never called the function") + } + if !confirmed || !strings.Contains(content, "ACCESS-CODE-4417") { + t.Fatalf("confirmed=%v content=%q", confirmed, content) + } +} diff --git a/apps/daemon/internal/agent/mcode/functions_test.go b/apps/daemon/internal/agent/mcode/functions_test.go new file mode 100644 index 000000000..325455cc5 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/functions_test.go @@ -0,0 +1,260 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type bearerTransport struct{ header string } + +func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", b.header) + return http.DefaultTransport.RoundTrip(r) +} + +// fakeFunctionTurn is the native side of a public function call: it reads the +// ACP Session's functions server, calls the tool and records what native got. +func fakeFunctionTurn(params json.RawMessage, update func(string, map[string]any), send func(any), promptID json.RawMessage) { + var session struct { + MCP []struct { + Name string `json:"name"` + URL string `json:"url"` + Headers []struct{ Name, Value string } + } `json:"mcpServers"` + } + if json.Unmarshal(params, &session) != nil || len(session.MCP) != 1 || session.MCP[0].Name != "functions" || writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("functions", "functions", "lookup", "lookup")) != nil { + os.Exit(20) + } + server := session.MCP[0] + update("tool_call", map[string]any{"toolCallId": "t1", "name": "mcp__functions__lookup", "status": "in_progress", "rawInput": map[string]any{"q": "x"}}) + client := mcp.NewClient(&mcp.Implementation{Name: "fake", Version: "1"}, nil) + connection, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: &http.Client{Transport: bearerTransport{server.Headers[0].Value}}}, nil) + if err != nil { + os.Exit(21) + } + result, err := connection.CallTool(context.Background(), &mcp.CallToolParams{Name: "lookup", Arguments: map[string]any{"q": "x"}}) + text := "error" + if err == nil { + text = result.Content[0].(*mcp.TextContent).Text + } + if os.WriteFile(os.Getenv("OAC_TEST_FN_RESULT"), []byte(text), 0600) != nil { + return + } + update("tool_call_update", map[string]any{"toolCallId": "t1", "status": "completed", "rawOutput": map[string]any{"details": map[string]any{"mcp": result}}}) + raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) +} + +func functionTurn(t *testing.T, scenario string) (*Session, <-chan proto.Envelope, string) { + t.Helper() + req := helperRequest(t, scenario, false) + req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} + record := t.TempDir() + "/result" + t.Setenv("OAC_TEST_FN_RESULT", record) + out := make(chan proto.Envelope, 32) + session, err := startTurn(t, t.Context(), req, out) + if err != nil { + t.Fatal(err) + } + return session, out, record +} +func waitFunctionCall(t *testing.T, out <-chan proto.Envelope) proto.FunctionCallPayload { + t.Helper() + for event := range out { + if event.Type == proto.TypeFunctionCall { + var call proto.FunctionCallPayload + _ = json.Unmarshal(event.Payload, &call) + return call + } + } + t.Fatal("no function call") + return proto.FunctionCallPayload{} +} + +// Native calls the served tool, blocks until the result arrives, receives it, +// and the receipt is confirmed by the native tool result. +func TestFunctionCallRoundTrip(t *testing.T) { + session, out, record := functionTurn(t, "functions") + call := waitFunctionCall(t, out) + if call.Name != "lookup" || string(call.Arguments) != `{"q":"x"}` { + t.Fatalf("call = %+v", call) + } + functions := session.executor.opts.Functions + if response, err := http.Post(functions.url, "application/json", strings.NewReader("{}")); err != nil || response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated request: %v", err) + } + text := "answer" + if err := session.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{DeliveryID: "d", CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(record); string(got) != "answer" { + t.Fatalf("native received %q", got) + } + confirmed := false + for event := range out { + var payload proto.ToolCallPayload + if event.Type == proto.TypeError { + t.Fatalf("error: %s", event.Payload) + } + if event.Type == proto.TypeToolCall && json.Unmarshal(event.Payload, &payload) == nil && payload.Stage == "after" && payload.Observation.Kind == "function" && payload.Observation.Status == "completed" { + confirmed = true + } + } + if !confirmed { + t.Fatal("function receipt was not observed") + } +} + +// A pending call fails when the Turn is cancelled, and closing the Session +// leaves no listener behind. +func TestFunctionCallCancellationReleasesPendingCall(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + functions := session.executor.opts.Functions + client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "1"}, nil) + connection, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: functions.url, HTTPClient: &http.Client{Transport: bearerTransport{"Bearer " + functions.token}}}, nil) + if err != nil { + t.Fatal(err) + } + released := make(chan error, 1) + go func() { + _, err := connection.CallTool(t.Context(), &mcp.CallToolParams{Name: "lookup", Arguments: map[string]any{}}) + released <- err + }() + call := waitFunctionCall(t, out) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := session.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case err := <-released: + if err == nil { + t.Fatal("pending native call succeeded") + } + case <-ctx.Done(): + t.Fatal("pending native call was not released") + } + if err := session.SubmitFunctionResult(ctx, proto.FunctionResultPayload{DeliveryID: "d", CallID: call.CallID, Content: []proto.InputContent{}}); err == nil { + t.Fatal("result accepted after cancellation") + } + if err := session.executor.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-functions.done: + default: + t.Fatal("function listener outlived the Session") + } +} + +// probeFunction is the native side of one call: it calls the tool over MCP and +// returns what native received. +func probeFunction(t *testing.T, functions *functionServer) <-chan *mcp.CallToolResult { + t.Helper() + client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "1"}, nil) + connection, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: functions.url, HTTPClient: &http.Client{Transport: bearerTransport{"Bearer " + functions.token}}}, nil) + if err != nil { + t.Fatal(err) + } + received := make(chan *mcp.CallToolResult, 1) + go func() { + result, _ := connection.CallTool(t.Context(), &mcp.CallToolParams{Name: "lookup", Arguments: map[string]any{}}) + received <- result + }() + return received +} + +func submitText(session *Session, callID, text string) error { + return session.SubmitFunctionResult(context.Background(), proto.FunctionResultPayload{DeliveryID: "d", CallID: callID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &text}}}) +} + +func nativeEnd(session *Session, status string, callID string) { + output := map[string]any{} + if callID != "" { + output["details"] = map[string]any{"mcp": map[string]any{"_meta": map[string]any{functionCallMeta: callID}}} + } + session.confirmFunction(toolUpdate{mcp: &mcpToolIdentity{server: "functions", tool: "lookup"}, Status: status, RawOutput: output}) +} + +// Calls of the same function run one after another; each gets its own result +// and its own receipt. +func TestFunctionCallsInSequence(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + functions := session.executor.opts.Functions + for _, answer := range []string{"first", "second"} { + received := probeFunction(t, functions) + call := waitFunctionCall(t, out) + submitted := make(chan error, 1) + go func() { submitted <- submitText(session, call.CallID, answer) }() + result := <-received + if got := result.Content[0].(*mcp.TextContent).Text; got != answer { + t.Fatalf("native received %q, want %q", got, answer) + } + nativeEnd(session, "completed", call.CallID) + if err := <-submitted; err != nil { + t.Fatal(err) + } + } +} + +// A call that native fails, such as at its call timeout, is withdrawn: a late +// result is refused at once and the next call still works. +func TestFunctionCallFailedByNativeIsWithdrawn(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + functions := session.executor.opts.Functions + received := probeFunction(t, functions) + late := waitFunctionCall(t, out) + nativeEnd(session, "failed", "") + for event := range out { + if event.Type == proto.TypeFunctionCallWithdrawn { + var withdrawn proto.FunctionCallWithdrawnPayload + if json.Unmarshal(event.Payload, &withdrawn) != nil || withdrawn.CallID != late.CallID { + t.Fatalf("withdrawn = %s", event.Payload) + } + break + } + } + if result := <-received; result != nil { + t.Fatal("released call returned a result") + } + if err := submitText(session, late.CallID, "late"); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatalf("late result: %v", err) + } + received = probeFunction(t, functions) + next := waitFunctionCall(t, out) + submitted := make(chan error, 1) + go func() { submitted <- submitText(session, next.CallID, "next") }() + if result := <-received; result.Content[0].(*mcp.TextContent).Text != "next" { + t.Fatal("next call did not get its result") + } + nativeEnd(session, "completed", next.CallID) + if err := <-submitted; err != nil { + t.Fatal(err) + } +} + +// A call still pending when the Turn ends is reported to native and refuses a result. +func TestFunctionCallPendingAtTurnEnd(t *testing.T) { + session, out, _ := functionTurn(t, "cancel-wait") + received := probeFunction(t, session.executor.opts.Functions) + call := waitFunctionCall(t, out) + session.stopFunctions() + if result := <-received; result != nil { + t.Fatal("pending call returned a result") + } + if err := submitText(session, call.CallID, "late"); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatalf("result after Turn end: %v", err) + } +} diff --git a/apps/daemon/internal/agent/mcode/harness_config_test.go b/apps/daemon/internal/agent/mcode/harness_config_test.go index 1de262c5d..0cc11b16d 100644 --- a/apps/daemon/internal/agent/mcode/harness_config_test.go +++ b/apps/daemon/internal/agent/mcode/harness_config_test.go @@ -8,11 +8,11 @@ import ( func TestNativeConfigDoesNotPretendToSupportParameters(t *testing.T) { req := testRequest(t) req.AgentOptions["harness_config"] = map[string]any{} - if _, err := prepareOptions(testStateRoot(t), req); err != nil { + if _, err := prepareOptions(testStateRoot(t), req, nil); err != nil { t.Fatal(err) } req.AgentOptions["harness_config"] = map[string]any{"temperature": 0.5} - if _, err := prepareOptions(testStateRoot(t), req); err != harnessconfig.ErrHarnessConfig { + if _, err := prepareOptions(testStateRoot(t), req, nil); 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 5f6937fc1..ce35f0f42 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")} + return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_COMPANION": dir} }, 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.go b/apps/daemon/internal/agent/mcode/mcp_observations.go index 364d8995b..a026881bd 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations.go @@ -10,22 +10,22 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) type mcpToolIdentity struct{ server, tool string } // MiniMax 0.4.12 atomically writes these assignments before exposing tools. // Read its exact mapping instead of reversing lossy native name normalization. -func (s *Session) environmentMCPIdentity(name string) (*mcpToolIdentity, error) { - if s.req.LocalEnvironment == nil || - !strings.HasPrefix(name, "mcp__") || strings.HasPrefix(name, "mcp__oac_workspace__") { +func (s *Session) mcpIdentity(name string) (*mcpToolIdentity, error) { + if !strings.HasPrefix(name, "mcp__") { return nil, nil } bindings, err := agent.ResolveMCPBindings(s.req) if err != nil { return nil, err } - if len(bindings) == 0 { + if len(bindings) == 0 && len(s.req.FunctionTools) == 0 { return nil, nil } raw, err := os.ReadFile(filepath.Join(s.opts.DataDir, "mcp-runtime-names.json")) @@ -50,12 +50,15 @@ func (s *Session) environmentMCPIdentity(name string) (*mcpToolIdentity, error) } var key []string declared := 0 + if server.Raw == declaration.FunctionServerLabel && len(s.req.FunctionTools) > 0 { + declared = 1 + } for _, item := range bindings { if item.ServerLabel == server.Raw { declared++ } } - if found != nil || declared != 1 || server.Raw == "oac_workspace" || tool.Raw == "" || + if found != nil || declared != 1 || tool.Raw == "" || json.Unmarshal([]byte(server.Key), &key) != nil || len(key) != 2 || key[0] != "configured" || key[1] != server.Raw { return nil, fmt.Errorf("mcode: ambiguous or undeclared native MCP identity") } @@ -68,7 +71,7 @@ func (s *Session) environmentMCPIdentity(name string) (*mcpToolIdentity, error) return found, nil } -func environmentMCPObservation(update toolUpdate, stage string) (*proto.ToolObservation, error) { +func mcpObservation(update toolUpdate, stage string) (*proto.ToolObservation, error) { if update.mcp == nil { return nil, nil } diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index 9f613f1a9..96084853f 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -16,7 +16,7 @@ func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { out := make(chan proto.Envelope, 16) 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()}}}, + LocalEnvironment: &proto.LocalEnvironment{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 { t.Fatal(err) @@ -164,18 +164,6 @@ func TestEnvironmentMCPResultMustMatchStartAndUnsettledCallsCloseOnce(t *testing } } -func TestEnvironmentMCPDoesNotPublishInternalWorkspaceUtilities(t *testing.T) { - s, out := mcpObservationSession(t) - for _, name := range []string{"workspace_read", "workspace_write", "workspace_edit", "workspace_glob", "workspace_grep"} { - if err := s.emitTool(toolUpdate{ID: name, Name: "mcp__oac_workspace__" + name, Status: "completed"}); err != nil { - t.Fatal(err) - } - } - if len(out) != 0 { - t.Fatal("internal workspace utility became a public MCP call") - } -} - func TestEnvironmentMCPNativeJSONRetainsIntegerPrecision(t *testing.T) { s, out := mcpObservationSession(t) frames := []string{ diff --git a/apps/daemon/internal/agent/mcode/model_provider.go b/apps/daemon/internal/agent/mcode/model_provider.go index 231198586..a3cab3554 100644 --- a/apps/daemon/internal/agent/mcode/model_provider.go +++ b/apps/daemon/internal/agent/mcode/model_provider.go @@ -8,8 +8,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) -func modelProviderConfig(raw any, model string) (map[string]any, error) { - provider, err := harnessconfiguration.Configuration().ParseProvider(raw) +func modelProviderConfig(raw any, model, effort string) (map[string]any, error) { + provider, err := harnessconfiguration.Declaration.Configuration.ParseProvider(raw) if err != nil { return nil, fmt.Errorf("mcode: %w", err) } @@ -26,12 +26,25 @@ func modelProviderConfig(raw any, model string) (map[string]any, error) { if len(provider.Headers) > 0 { options["headers"] = maps.Clone(provider.Headers) } + native := map[string]any{"name": model} + // Absent limits are omitted so the native defaults apply. + limit := map[string]any{} + if provider.ContextWindow > 0 { + limit["context"] = provider.ContextWindow + } + if provider.MaxOutputTokens > 0 { + limit["output"] = provider.MaxOutputTokens + } + if len(limit) > 0 { + native["limit"] = limit + } + if effort != "" { + // The Session selects this effort through ACP thinkingEffort. + native["thinking"] = map[string]any{"effortOptions": []string{effort}} + } return map[string]any{ "name": "Configured provider", "kind": "custom", "enabled": true, "api": api, "options": options, - "models": map[string]any{model: map[string]any{ - "name": model, "tool_call": true, - "limit": map[string]any{"context": provider.ContextWindow, "output": provider.MaxOutputTokens}, - }}, + "models": map[string]any{model: native}, }, nil } diff --git a/apps/daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go index ada80d770..c6dfb0cf4 100644 --- a/apps/daemon/internal/agent/mcode/model_provider_test.go +++ b/apps/daemon/internal/agent/mcode/model_provider_test.go @@ -17,7 +17,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { req.AgentOptions["model_provider"].(map[string]any)["protocol"] = tc.protocol req.AgentOptions["model_provider"].(map[string]any)["headers"] = map[string]string{"X-Activity": "private-header"} req.AgentOptions["model"] = "chosen-model" - opts, err := prepareOptions(testStateRoot(t), req) + opts, err := prepareOptions(testStateRoot(t), req, nil) if err != nil { t.Fatal(err) } @@ -33,7 +33,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { "name": "Configured provider", "kind": "custom", "enabled": true, "api": tc.api, "options": map[string]any{"baseURL": "https://provider.example/v1", "apiKey": "fixture-key", "headers": map[string]any{"X-Activity": "private-header"}}, "models": map[string]any{"chosen-model": map[string]any{ - "name": "chosen-model", "tool_call": true, + "name": "chosen-model", "limit": map[string]any{"context": float64(64000), "output": float64(4096)}, }}, }} @@ -44,6 +44,43 @@ func TestOptionsModelProviderProtocols(t *testing.T) { } } +func TestOptionsOmitAbsentTokenLimits(t *testing.T) { + for _, tc := range []struct { + name string + unset []string + want any + }{ + {"none", []string{"context_window", "max_output_tokens"}, nil}, + {"context only", []string{"max_output_tokens"}, map[string]any{"context": float64(64000)}}, + } { + t.Run(tc.name, func(t *testing.T) { + req := testRequest(t) + for _, field := range tc.unset { + delete(req.AgentOptions["model_provider"].(map[string]any), field) + } + opts, err := prepareOptions(testStateRoot(t), req, nil) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config struct { + CustomProvider map[string]struct { + Models map[string]map[string]any `json:"models"` + } `json:"custom_provider"` + } + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + if got := config.CustomProvider["oac"].Models[opts.Model]["limit"]; !reflect.DeepEqual(got, tc.want) { + t.Fatalf("limit = %v, want %v", got, tc.want) + } + }) + } +} + func TestOptionsRejectInvalidProvider(t *testing.T) { for _, tc := range []struct { name, field string @@ -53,15 +90,13 @@ func TestOptionsRejectInvalidProvider(t *testing.T) { {"protocol alias", "protocol", "chat-completions"}, {"remote HTTP", "base_url", "http://provider.example/v1"}, {"empty key", "api_key", ""}, - {"missing context", "context_window", 0}, - {"missing output", "max_output_tokens", 0}, {"excess output", "max_output_tokens", 64001}, {"native options", "options", map[string]any{"apiKey": "fixture-key"}}, } { 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(testStateRoot(t), req); err == nil { + if _, err := prepareOptions(testStateRoot(t), req, nil); err == nil { t.Fatal("invalid model provider accepted") } }) @@ -70,7 +105,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(testStateRoot(t), req); err == nil { + if _, err := prepareOptions(testStateRoot(t), req, nil); err == nil { t.Fatal("retired native provider option accepted") } }) @@ -79,7 +114,34 @@ 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(testStateRoot(t), req); err != nil { + if _, err := prepareOptions(testStateRoot(t), req, nil); err != nil { + t.Fatal(err) + } +} + +func TestOptionsWriteNativeThinkingEffort(t *testing.T) { + req := testRequest(t) + req.ExecutionControls.ReasoningEffort = "high" + opts, err := prepareOptions(testStateRoot(t), req, nil) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { t.Fatal(err) } + var config struct { + CustomProvider map[string]struct { + Models map[string]struct { + Thinking map[string]any `json:"thinking"` + } `json:"models"` + } `json:"custom_provider"` + } + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + want := map[string]any{"effortOptions": []any{"high"}} + if got := config.CustomProvider["oac"].Models["fixture"].Thinking; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %v", got) + } } diff --git a/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go b/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go new file mode 100644 index 000000000..60cfa4c94 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/native_effort_mcp_test.go @@ -0,0 +1,168 @@ +package mcode + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// This opt-in test makes real model calls. It sends the native model traffic +// through a local recording proxy and a text-placement HTTP MCP fixture, then +// checks the effort and the final user text at the model and the MCP call. +func TestNativeMCodeEffortMCPAndPlainText(t *testing.T) { + binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if binary == "" || options == "" { + t.Skip("native executable and private provider options required") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal("private provider options unavailable") + } + req := testRequest(t) + req.RunID, req.Input = "", nil + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private provider options") + } + provider := req.AgentOptions["model_provider"].(map[string]any) + upstream, err := url.Parse(provider["base_url"].(string)) + if err != nil { + t.Fatal("invalid provider base URL") + } + var mu sync.Mutex + var bodies [][]byte + proxy := httptest.NewServer(&httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(upstream) + if body, err := io.ReadAll(r.In.Body); err == nil { + mu.Lock() + bodies = append(bodies, body) + mu.Unlock() + r.Out.Body = io.NopCloser(bytes.NewReader(body)) + } + }, + }) + defer proxy.Close() + provider["base_url"] = proxy.URL + + var called sync.Map + mcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var rpc struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + _ = json.NewDecoder(r.Body).Decode(&rpc) + if len(rpc.ID) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + var result any + switch rpc.Method { + case "initialize": + result = map[string]any{"protocolVersion": "2025-03-26", "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "fixture", "version": "1"}} + case "tools/list": + result = map[string]any{"tools": []any{map[string]any{"name": "lookup_code", "description": "Returns the secret access code. Always call it when asked for the access code.", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{}}}}} + case "tools/call": + called.Store(rpc.Params.Name, true) + result = map[string]any{"content": []any{map[string]any{"type": "text", "text": "ACCESS-CODE-4417"}}} + default: + result = map[string]any{} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": result}) + })) + defer mcp.Close() + + req.ExecutionControls.ReasoningEffort = "high" + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: mcp.URL + "/mcp"}} + value, err := NewExecutorFactory(testStateRoot(t), nil)(ctx, req) + if err != nil { + t.Fatal("native Executor preparation failed") + } + e := value.(*executor) + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + _ = e.Close(cleanup) + }() + out := make(chan proto.Envelope, 256) + turn, err := e.StartTurn(ctx, "turn", proto.TextInput("Use the MCP tool to get the access code, then reply with only the code."), out) + if err != nil { + t.Fatal("native Turn admission failed") + } + var content string + observed := false + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatal("native Turn reported an error") + case proto.TypeToolCall: + var call proto.ToolCallPayload + if json.Unmarshal(event.Payload, &call) == nil && call.Observation != nil && call.Observation.Kind == "mcp" && call.Observation.Server == "fixture" && call.Observation.Name == "lookup_code" { + observed = true + } + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(event.Payload, &done) + content = done.Content + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal("native Turn did not settle") + } + if _, ok := called.Load("lookup_code"); !ok || !observed || !strings.Contains(content, "ACCESS-CODE-4417") { + t.Fatalf("MCP tool was not listed, called and observed: called=%v observed=%v", ok, observed) + } + mu.Lock() + defer mu.Unlock() + effort, plain := false, false + for _, body := range bodies { + var request struct { + OutputConfig map[string]any `json:"output_config"` + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + } + if json.Unmarshal(body, &request) != nil { + continue + } + effort = effort || request.OutputConfig["effort"] == "high" + if len(request.Messages) > 0 && request.Messages[0].Role == "user" { + if blocks, ok := request.Messages[0].Content.([]any); ok { + for _, block := range blocks { + if text, _ := block.(map[string]any)["text"].(string); strings.HasSuffix(text, "reply with only the code.") { + plain = true + } + } + } else if text, _ := request.Messages[0].Content.(string); strings.HasSuffix(text, "reply with only the code.") { + plain = true + } + } + } + t.Logf("model requests=%d effort high on wire=%v user text ends without newlines=%v", len(bodies), effort, plain) + if !effort || !plain { + t.Fatal("model request did not carry the effort and the unmodified user text") + } +} diff --git a/apps/daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go index ba3c33247..48b01ff6f 100644 --- a/apps/daemon/internal/agent/mcode/native_test.go +++ b/apps/daemon/internal/agent/mcode/native_test.go @@ -62,6 +62,7 @@ func testNativeMCodeHeaders(t *testing.T, protocol string, workspace bool) { mu.Unlock() if protocol == "chat_completions" { tool := "" + arguments := "" if !toolSent.Load() { for _, raw := range body["tools"].([]any) { function, ok := raw.(map[string]any)["function"].(map[string]any) @@ -69,14 +70,26 @@ func testNativeMCodeHeaders(t *testing.T, protocol string, workspace bool) { function = raw.(map[string]any) } name, _ := function["name"].(string) - if strings.HasSuffix(name, "workspace_read") && toolSent.CompareAndSwap(false, true) { + if name == "read" && toolSent.CompareAndSwap(false, true) { tool = name + parameters, _ := function["parameters"].(map[string]any) + properties, _ := parameters["properties"].(map[string]any) + for _, key := range []string{"file_path", "filePath", "path"} { + if _, ok := properties[key]; ok { + raw, _ := json.Marshal(map[string]string{key: "fixture.txt"}) + arguments = string(raw) + break + } + } + if arguments == "" { + t.Error("native read tool has no supported file path parameter") + } break } } } - writeNativeChatResponse(w, tool) + writeNativeChatResponse(w, tool, arguments) } else { writeNativeResponse(w) } @@ -92,6 +105,9 @@ func testNativeMCodeHeaders(t *testing.T, protocol string, workspace bool) { defer cancel() var config *WorkspaceConfig if workspace { + if os.Getenv("OAC_RUNTIME_MCODE_COMPANION") == "" { + t.Skip("set OAC_RUNTIME_MCODE_COMPANION for native workspace test") + } root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "fixture.txt"), []byte("HEADER-TOOL-CONTINUATION"), 0600); err != nil { t.Fatal(err) @@ -100,9 +116,9 @@ func testNativeMCodeHeaders(t *testing.T, protocol string, workspace bool) { if err != nil { t.Fatal(err) } - config = &WorkspaceConfig{Binary: binary, Node: node, Bridge: filepath.Join(filepath.Dir(filepath.Dir(binary)), "bridge.mjs"), Directory: root, Network: "enabled", Scratch: t.TempDir()} + config = &WorkspaceConfig{Binary: binary, Node: node, Companion: os.Getenv("OAC_RUNTIME_MCODE_COMPANION"), Directory: root} req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: "headers-workspace", WorkspaceRoot: root, NetworkAccess: "enabled"} + req.LocalEnvironment = &proto.LocalEnvironment{ID: "headers-workspace", WorkspaceRoot: root} } factory := NewExecutorFactory(stateRoot, config) owner, err := factory(ctx, req) @@ -197,12 +213,12 @@ func writeNativeResponse(w http.ResponseWriter) { send("message_stop", map[string]string{"type": "message_stop"}) } -func writeNativeChatResponse(w http.ResponseWriter, tool string) { +func writeNativeChatResponse(w http.ResponseWriter, tool, arguments string) { w.Header().Set("Content-Type", "text/event-stream") delta := map[string]any{"role": "assistant", "content": "OAC-MCODE-OK"} finish := "stop" if tool != "" { - delta = map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"index": 0, "id": "call_fixture", "type": "function", "function": map[string]any{"name": tool, "arguments": `{"path":"fixture.txt"}`}}}} + delta = map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"index": 0, "id": "call_fixture", "type": "function", "function": map[string]any{"name": tool, "arguments": arguments}}}} finish = "tool_calls" } for _, choice := range []map[string]any{{"index": 0, "delta": delta, "finish_reason": nil}, {"index": 0, "delta": map[string]any{}, "finish_reason": finish}} { diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index f34f57b53..703fdf0d7 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -16,11 +16,22 @@ type launchOptions struct { Dir, DataDir, Model string Env []string MCP []map[string]any + Functions *functionServer } -func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload) (launchOptions, error) { +// workspaceProfile selects the native tools of a Session that has an Environment. +type workspaceProfile struct { + // ToolEnv is the Runtime's frozen tool environment as KEY=value entries. + ToolEnv []string + // WebFetch keeps the native web_fetch tool. + WebFetch bool +} + +// prepareOptions writes the native configuration. A nil workspace is the none +// placement: the native process has no builtin file, process or fetch tools. +func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload, workspace *workspaceProfile) (launchOptions, error) { var result launchOptions - if _, err := harnessconfiguration.Configuration().PrepareHarnessConfig(req.AgentOptions); err != nil { + if _, err := harnessconfiguration.Declaration.Configuration.PrepareHarnessConfig(req.AgentOptions); err != nil { return result, err } if err := validateExecutionRequest(req); err != nil { @@ -46,25 +57,27 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload) (launchO if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { return result, err } - config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} + config := map[string]any{} + effort := "" + if req.ExecutionControls != nil { + effort = req.ExecutionControls.ReasoningEffort + } result.Model = optionString(opts, "model") if result.Model == "" { return result, fmt.Errorf("mcode: model is required") } - provider, err := modelProviderConfig(opts["model_provider"], result.Model) + provider, err := modelProviderConfig(opts["model_provider"], result.Model, effort) if err != nil { return result, err } config["custom_provider"] = map[string]any{"oac": provider} - 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" + config["agents"] = map[string]any{"default": agentCapabilities(req, workspace)} + // Unattended: no ask-user. Native mcode-tools needs a MiniMax account sign-in that Core does not hold. + // Thread goals schedule continuation Turns after the Turn ends. + config["askUser"] = map[string]bool{"enabled": false} + config["beta"] = map[string]bool{"mcodeTools": false, "threadGoal": false} + // Unattended: tools, including MCP tools on environment none, run without approval prompts. + config["permissionMode"] = "bypassPermissions" data, err := json.Marshal(config) if err != nil { return result, err @@ -72,10 +85,12 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload) (launchO if err := os.WriteFile(filepath.Join(result.DataDir, "config.yaml"), data, 0o600); err != nil { return result, err } - 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 + result.Env = executionEnvironment() + if workspace != nil { + result.Env = append(result.Env, workspace.ToolEnv...) } + // Background bash outlives the Turn and escapes the process group; Subagent task output stays. + result.Env = append(result.Env, "OAC_RUNTIME_MCODE_BACKGROUND_BASH=off") if !req.DisableSubagents { result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) } else { @@ -83,10 +98,32 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload) (launchO } // The adapter owns the native state location, including after cold resume. result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) - result.MCP = []map[string]any{} return result, nil } +// agentCapabilities is the native agents.default ceiling. A native ID list is +// all-or-nothing: one unknown ID drops the whole list and enables every tool. +func agentCapabilities(req proto.PromptRequestPayload, workspace *workspaceProfile) map[string]any { + tools := []string{} + if workspace != nil { + // website_deploy needs a MiniMax account and stays out of the list. + tools = append(tools, "read", "write", "edit", "bash", "grep", "glob", "todowrite") + if workspace.WebFetch { + tools = append(tools, "web_fetch") + } + } + return map[string]any{ + "tools": tools, + "features": map[string]bool{ + // The mavis tool carries cron scheduling that runs beyond a Turn, plus session send, agent create and MCP create. + "mavis": false, + "delegation": !req.DisableSubagents, + // Native web search calls MiniMax's cloud with a MiniMax account credential that Core does not hold. + "webSearch": false, + }, + } +} + func optionString(options map[string]any, key string) string { value, _ := options[key].(string) return value diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 5018a743f..989475c9b 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -13,7 +13,7 @@ import ( func TestOptionsRefreshManagedState(t *testing.T) { t.Setenv("MINIMAX_DATA_DIR", "/wrong") req := testRequest(t) - opts, err := prepareOptions(testStateRoot(t), req) + opts, err := prepareOptions(testStateRoot(t), req, nil) if err != nil { t.Fatal(err) } @@ -31,7 +31,7 @@ func TestOptionsRefreshManagedState(t *testing.T) { } req.AgentOptions["system_prompt"] = "" req.AgentSessionID = "native-1" - refreshed, err := prepareOptions(testStateRoot(t), req) + refreshed, err := prepareOptions(testStateRoot(t), req, nil) if err != nil { t.Fatal(err) } @@ -50,11 +50,11 @@ func TestOptionsRefreshManagedState(t *testing.T) { if json.Unmarshal(data, &cfg) != nil { t.Fatal("invalid config") } - if cfg["permissionMode"] != "auto" { - t.Fatal("native permission mode changed") + if cfg["permissionMode"] != "bypassPermissions" { + t.Fatal("MCP tools on environment none can wait for approval") } - if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { - t.Fatal("external discovery enabled") + if _, set := cfg["skills"]; set { + t.Fatal("native Skill discovery was overridden") } info, _ := os.Stat(filepath.Join(opts.DataDir, "config.yaml")) if info.Mode().Perm() != 0600 { @@ -75,7 +75,7 @@ func TestOptionsRejectDroppedContext(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := testRequest(t) tt.edit(&req) - if _, err := prepareOptions(testStateRoot(t), req); err == nil { + if _, err := prepareOptions(testStateRoot(t), req, nil); err == nil { t.Fatal("expected validation failure") } }) diff --git a/apps/daemon/internal/agent/mcode/protocol.go b/apps/daemon/internal/agent/mcode/protocol.go index 116d8c024..516ca2183 100644 --- a/apps/daemon/internal/agent/mcode/protocol.go +++ b/apps/daemon/internal/agent/mcode/protocol.go @@ -42,11 +42,9 @@ type toolUpdate struct { type sessionUpdate struct { SessionID string `json:"sessionId"` Update struct { - Kind string `json:"sessionUpdate"` - Content struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` + Kind string `json:"sessionUpdate"` + // Message chunks carry one content block; native tool updates carry a list, which is not read. + Content json.RawMessage `json:"content"` toolUpdate } `json:"update"` } diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index 528d0338b..826214f7d 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -48,6 +48,7 @@ type Session struct { previousNativeTurns map[string]bool rootCompletedAtMS *int64 subagentHistoryReady bool + functions *functionCalls } var _ agent.Session = (*Session)(nil) @@ -65,7 +66,7 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti } 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{}), tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), functions: &functionCalls{pending: map[string]*pendingFunction{}, stopped: make(chan struct{})}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} } func (s *Session) prepareNative() error { @@ -74,7 +75,7 @@ func (s *Session) prepareNative() error { Meta struct { Subagents struct { Version, MaxConcurrent int - WorkspaceTools string + BackgroundBash *bool } `json:"oac/subagents"` } `json:"_meta"` } @@ -84,8 +85,11 @@ func (s *Session) prepareNative() error { if initialized.ProtocolVersion != 1 { return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) } - 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") + // Only the patched CLI reports that background bash is off; an unpatched one would leave work after a Turn. + meta := initialized.Meta.Subagents + if meta.Version != 2 || meta.BackgroundBash == nil || *meta.BackgroundBash || + (!s.req.DisableSubagents && (s.req.MaxConcurrentSubagents == nil || meta.MaxConcurrent != *s.req.MaxConcurrentSubagents)) { + return fmt.Errorf("mcode: patched native CLI is unavailable") } params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} method := "session/new" @@ -106,10 +110,15 @@ func (s *Session) prepareNative() error { s.mu.Lock() s.sessionID = session.SessionID s.mu.Unlock() - model, err := advertisedModel(session.ConfigOptions, s.opts.Model) + // A model with effort options is advertised only with its "thinking" variant. + variant := "" + if s.req.ExecutionControls != nil && s.req.ExecutionControls.ReasoningEffort != "" { + variant = "thinking" + } + model, err := advertisedModel(session.ConfigOptions, s.opts.Model, variant) if err != nil && s.req.AgentSessionID != "" && !slices.ContainsFunc(session.ConfigOptions, func(option configOption) bool { return option.ID == "model" }) { // Native load omits the selector when its persisted model was removed; selection still validates against the current catalog. - model = "m:custom_provider%3Aoac:" + strings.ReplaceAll(url.QueryEscape(s.opts.Model), "+", "%20") + ":v:" + model = "m:custom_provider%3Aoac:" + strings.ReplaceAll(url.QueryEscape(s.opts.Model), "+", "%20") + ":v:" + variant err = nil } if err != nil { @@ -119,6 +128,10 @@ func (s *Session) prepareNative() error { if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "model", "value": model}, nil, false); err != nil { return err } + if variant != "" { + // A selected variant takes no model default, so the Session's effort is set explicitly. + return s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "thinkingEffort", "value": s.req.ExecutionControls.ReasoningEffort}, nil, false) + } return nil } @@ -148,7 +161,7 @@ func (s *Session) executePrompt(prompt string) error { } // Select the advertised custom model, rather than using mcode's native default. -func advertisedModel(options []configOption, model string) (string, error) { +func advertisedModel(options []configOption, model, variant string) (string, error) { for _, option := range options { if option.ID != "model" { continue @@ -166,7 +179,7 @@ func advertisedModel(options []configOption, model string) (string, error) { if err != nil { continue } - if provider == "custom_provider:oac" && id == model && (parts[3] == "u" || (len(parts) == 5 && parts[3] == "v" && parts[4] == "")) { + if provider == "custom_provider:oac" && id == model && (parts[3] == "u" || (len(parts) == 5 && parts[3] == "v" && parts[4] == variant)) { return candidate.Value, nil } } diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index 044a1bb35..6aadc85e9 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -6,7 +6,9 @@ import ( "encoding/json" "os" "path/filepath" + "strconv" "strings" + "sync" "testing" "time" @@ -18,7 +20,7 @@ func testRequest(t *testing.T) proto.PromptRequestPayload { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) 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"}} + }, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} } // helperRequest selects a protocol fixture scenario as the native CLI. @@ -161,7 +163,7 @@ func TestResumeSelectsModelWhenNativeSelectorIsMissing(t *testing.T) { } func TestSessionFailuresAreReported(t *testing.T) { - for _, scenario := range []string{"malformed", "exit", "unknown-model"} { + for _, scenario := range []string{"malformed", "exit", "unknown-model", "background-bash"} { t.Run(scenario, func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() @@ -204,7 +206,10 @@ func TestMCodeProcess(t *testing.T) { return } encoder := json.NewEncoder(os.Stdout) + var sendMu sync.Mutex send := func(value any) { + sendMu.Lock() + defer sendMu.Unlock() if err := encoder.Encode(value); err != nil { os.Exit(2) } @@ -214,7 +219,7 @@ func TestMCodeProcess(t *testing.T) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "native-1", "update": fields}}) } scanner := bufio.NewScanner(os.Stdin) - var promptID json.RawMessage + var promptID, sessionParams json.RawMessage prompts, configurations := 0, 0 for scanner.Scan() { var frame rpcFrame @@ -254,8 +259,12 @@ func TestMCodeProcess(t *testing.T) { if scenario == "unattended" && strings.Contains(string(frame.Params), "elicitation") { os.Exit(7) } - result = map[string]int{"protocolVersion": 1} + // The patched CLI reports its Subagent limit and whether bash may outlive a Turn. + limit, _ := strconv.Atoi(os.Getenv("OAC_RUNTIME_MCODE_MAX_SUBAGENTS")) + result = map[string]any{"protocolVersion": 1, "_meta": map[string]any{"oac/subagents": map[string]any{ + "version": 2, "maxConcurrent": limit, "backgroundBash": scenario == "background-bash" || os.Getenv("OAC_RUNTIME_MCODE_BACKGROUND_BASH") != "off"}}} case "session/new", "session/load": + sessionParams = frame.Params if scenario == "prepared-mcp-cancel" { if writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("proof.server", "proof_server", "read.status", "read_status")) != nil { os.Exit(12) @@ -292,7 +301,7 @@ func TestMCodeProcess(t *testing.T) { Prompt []map[string]string `json:"prompt"` } _ = json.Unmarshal(frame.Params, &input) - if len(input.Prompt) != 2 { + if len(input.Prompt) != 1 { os.Exit(9) } if strings.HasPrefix(scenario, "executor") { @@ -306,7 +315,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": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "true"}}) + update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "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}) @@ -317,6 +326,11 @@ func TestMCodeProcess(t *testing.T) { } continue } + if scenario == "functions" { + promptID = frame.ID + go fakeFunctionTurn(sessionParams, update, send, promptID) + continue + } if scenario == "prepared-mcp-cancel" { promptID = frame.ID update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) @@ -345,7 +359,7 @@ func TestMCodeProcess(t *testing.T) { } update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) - update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "cat fixture.txt"}}) + update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "bash", "status": "in_progress", "rawInput": map[string]any{"command": "cat fixture.txt"}}) for range 2 { update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) } diff --git a/apps/daemon/internal/agent/mcode/steering.go b/apps/daemon/internal/agent/mcode/steering.go index 005f5c859..21ae074e0 100644 --- a/apps/daemon/internal/agent/mcode/steering.go +++ b/apps/daemon/internal/agent/mcode/steering.go @@ -12,10 +12,6 @@ import ( var _ agent.DurableSteerer = (*Session)(nil) -func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return s.SteerWithReceipt(ctx, input, nil) -} - // 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 { s.mu.Lock() @@ -53,9 +49,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP s.markInputUncertain() return fmt.Errorf("mcode: input transport failed") } - if written != nil { - written() - } + written() var frame rpcFrame var stopped error select { diff --git a/apps/daemon/internal/agent/mcode/subagent_messages.go b/apps/daemon/internal/agent/mcode/subagent_messages.go index 00f20d105..a4c9cbabb 100644 --- a/apps/daemon/internal/agent/mcode/subagent_messages.go +++ b/apps/daemon/internal/agent/mcode/subagent_messages.go @@ -34,11 +34,6 @@ func (s *Session) projectSubagentMessages(session nativeSubagentSession, turn na if message.Role != "assistant" { continue } - if message.Data.Thinking != "" { - if err := emit(message.ID+":reasoning", "reasoning", map[string]any{"status": "completed", "summary": []map[string]string{{"type": "summary_text", "text": message.Data.Thinking}}}); err != nil { - return err - } - } if message.Data.Text != "" { text := message.Data.Text if err := emit(message.ID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: message.ID, Status: "completed", Text: &text}); err != nil { @@ -72,14 +67,14 @@ func (s *Session) projectSubagentMessages(session nativeSubagentSession, turn na status = "failed" } update := toolUpdate{ID: tool.ID, Name: tool.Name, Status: status, RawInput: args, RawOutput: result} - observation := workspaceToolObservation(update, "after") + observation := s.nativeToolObservation(update, "after") if observation == nil { var err error - update.mcp, err = s.environmentMCPIdentity(update.Name) + update.mcp, err = s.mcpIdentity(update.Name) if err != nil { return err } - observation, err = environmentMCPObservation(update, "after") + observation, err = mcpObservation(update, "after") if err != nil { return err } diff --git a/apps/daemon/internal/agent/mcode/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go index 65c05a7b2..57c5a5749 100644 --- a/apps/daemon/internal/agent/mcode/subagents.go +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -35,9 +35,8 @@ type nativeSubagentMessage struct { ID, TurnID, Role string CreatedAt int64 Data struct { - Text string `json:"msg_content"` - Thinking string `json:"thinking_content"` - Tools []nativeSubagentTool `json:"tool_calls"` + Text string `json:"msg_content"` + Tools []nativeSubagentTool `json:"tool_calls"` } } type nativeSubagentTool struct { @@ -53,9 +52,9 @@ type nativeSubagentTask struct { } func subagentReader() (string, string, error) { - node, bridge := os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") - reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") - for _, path := range []string{node, bridge, reader} { + node := os.Getenv("OAC_RUNTIME_MCODE_NODE") + reader := filepath.Join(os.Getenv("OAC_RUNTIME_MCODE_COMPANION"), "subagent-snapshot.mjs") + for _, path := range []string{node, reader} { resolved, err := filepath.EvalSymlinks(path) if err != nil || !filepath.IsAbs(path) || resolved != path { return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") diff --git a/apps/daemon/internal/agent/mcode/subagents_test.go b/apps/daemon/internal/agent/mcode/subagents_test.go index 25a1773af..e82e4f3b9 100644 --- a/apps/daemon/internal/agent/mcode/subagents_test.go +++ b/apps/daemon/internal/agent/mcode/subagents_test.go @@ -40,7 +40,8 @@ func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { } first := project(childSnapshot(t)) second := project(childSnapshot(t)) - if len(first) != 6 { + // Native thinking is not reported: MiniMax Code admits no reasoning summary. + if len(first) != 5 { t.Fatalf("got %d events", len(first)) } for i, event := range first { @@ -48,7 +49,7 @@ func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { t.Fatal("cold snapshot identities changed") } } - if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[5].Type != proto.TypeSubagentTurn { + if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[4].Type != proto.TypeSubagentTurn { t.Fatal("identity/turn/item ordering changed") } var input proto.SubagentItemPayload @@ -72,10 +73,9 @@ func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { // The ACP cancelled response may precede the root native Turn becoming terminal. func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { dir := t.TempDir() - node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + node := filepath.Join(dir, "node") for name, data := range map[string]string{ - node: "#!/bin/sh\ncat \"$3/snapshot.json\"\n", - bridge: "", + node: "#!/bin/sh\ncat \"$3/snapshot.json\"\n", filepath.Join(dir, "subagent-snapshot.mjs"): "", } { if err := os.WriteFile(name, []byte(data), 0700); err != nil { @@ -83,7 +83,7 @@ func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { } } t.Setenv("OAC_RUNTIME_MCODE_NODE", node) - t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + t.Setenv("OAC_RUNTIME_MCODE_COMPANION", dir) for _, status := range []string{"accepted", "aborted"} { t.Run(status, func(t *testing.T) { raw := []byte(`{"version":1,"complete":true,"rootSessionId":"root","sessions":[{"id":"root","turns":[{"id":"current","status":"` + status + `"}]}]}`) diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go index fd047583a..66ffa720e 100644 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -2,10 +2,10 @@ package mcode import ( "bytes" - "encoding/json" "io/fs" "os" "path/filepath" + "slices" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" @@ -37,9 +37,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T t.Setenv("OAC_RUNTIME_SESSION_ID", session) t.Setenv("OAC_RUNTIME_WORKSPACE", config.Directory) t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", t.TempDir()) - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", "enabled") - t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", "") - req.AgentStateKey = "agents-api-" + session + req.AgentStateKey = session req.LocalEnvironment.ID, req.LocalEnvironment.WorkspaceDirectory = environment, config.Directory req.LocalEnvironment.Capabilities = true req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{plugin}} @@ -64,21 +62,18 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T if err != nil { t.Fatal(err) } - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) - if err != nil { - t.Fatal(err) - } - var profile struct { - ToolEnvFile string `json:"toolEnvFile"` + // The native process, and so its tools, sees the Runtime snapshot, not a later change to the source. + for _, want := range []string{"MCP_TEST_TOKEN=fixture-bearer-canary", "TOOL_SECRET=fixture-tool-canary"} { + if !slices.Contains(opts.Env, want) { + t.Fatal("native environment lacks the frozen tool environment", want) + } } - if err := json.Unmarshal(raw, &profile); err != nil { + // Assert the actual env-selected HTTP credential, not a manually injected token. + servers, err := runtimeMCP(prepared) + if err != nil { t.Fatal(err) } - if profile.ToolEnvFile != filepath.Join(initialization, "tool-env.json") { - t.Fatal("native profile did not retain the Runtime snapshot reference") - } - // Assert the actual env-selected HTTP credential, not a manually injected token. - headers, ok := opts.MCP[1]["headers"].([]map[string]string) + headers, ok := servers[0]["headers"].([]map[string]string) if !ok || len(headers) != 1 || headers[0]["name"] != "Authorization" || headers[0]["value"] != "Bearer fixture-bearer-canary" { t.Fatal("frozen MCP credential was lost or replaced") } diff --git a/apps/daemon/internal/agent/mcode/tool_observations.go b/apps/daemon/internal/agent/mcode/tool_observations.go index 1dbd6d643..9267669d2 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations.go +++ b/apps/daemon/internal/agent/mcode/tool_observations.go @@ -2,24 +2,34 @@ package mcode import ( "encoding/json" + "math" + "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) func (s *Session) emitToolStage(update toolUpdate, stage string) error { + // Public functions report through the function call, not as MCP observations. + if update.mcp != nil && update.mcp.server == declaration.FunctionServerLabel { + if stage == "after" && update.Status != "incomplete" { + s.confirmFunction(update) + } + return nil + } payload := proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: stage, Args: update.RawInput} if stage == "after" { payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} } - payload.Observation = workspaceToolObservation(update, stage) + payload.Observation = s.nativeToolObservation(update, stage) if payload.Observation == nil { var err error - payload.Observation, err = environmentMCPObservation(update, stage) + payload.Observation, err = mcpObservation(update, stage) if err != nil { return err } } - // Native task/skill bookkeeping has no qualified public item mapping. + // Native task/Skill/file bookkeeping has no qualified public item mapping. if payload.Observation == nil { return nil } @@ -27,35 +37,68 @@ func (s *Session) emitToolStage(update toolUpdate, stage string) error { return nil } -func workspaceToolObservation(update toolUpdate, stage string) *proto.ToolObservation { - if update.Name != "mcp__oac_workspace__workspace_bash" { - return nil - } - command, _ := update.RawInput["command"].(string) - if command == "" { - return nil - } - cwd := "/workspace" - result := &proto.ToolObservation{Kind: "command", Command: command, Cwd: &cwd, Status: "in_progress"} - if stage == "after" { - result.Status = update.Status - // ACP reports MCP content but no structured exit code or duration. - raw, _ := json.Marshal(update.RawOutput) - var output struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` +// nativeToolObservation projects the native bash and web_fetch tools. A tool +// without its arguments yet has no observation. +func (s *Session) nativeToolObservation(update toolUpdate, stage string) *proto.ToolObservation { + switch update.Name { + case "bash": + command, _ := update.RawInput["command"].(string) + if command == "" { + return nil } - if json.Unmarshal(raw, &output) == nil && output.Content != nil { - text := "" - for _, part := range output.Content { - if part.Type == "text" { - text += part.Text - } + cwd := s.opts.Dir + result := &proto.ToolObservation{Kind: "command", Command: command, Cwd: &cwd, Status: "in_progress"} + if stage == "after" { + result.Status = update.Status + result.Output, _ = json.Marshal(toolResultText(update.RawOutput)) + // Only a finished command reports its exit code; a failed one carries prose, which is not parsed. + if code, ok := nativeExitCode(update.RawOutput); ok { + result.ExitCode = &code } - result.Output, _ = json.Marshal(text) } + return result + case "web_fetch": + url, _ := update.RawInput["url"].(string) + if url == "" { + return nil + } + result := &proto.ToolObservation{Kind: "web_search", Status: "in_progress", Action: &proto.ToolWebSearchAction{Type: "open_page", URL: &url}} + if stage == "after" { + result.Status = update.Status + } + return result + } + return nil +} + +// toolResultText joins the text parts of a native tool result; a Subagent history keeps plain text results. +func toolResultText(output any) string { + if text, ok := output.(string); ok { + return text + } + result, _ := output.(map[string]any) + parts, _ := result["content"].([]any) + var text strings.Builder + for _, part := range parts { + if item, _ := part.(map[string]any); item["type"] == "text" { + value, _ := item["text"].(string) + text.WriteString(value) + } + } + return text.String() +} + +// nativeExitCode reads the structured exit code of a finished bash command. +func nativeExitCode(output any) (int64, bool) { + result, _ := output.(map[string]any) + details, _ := result["details"].(map[string]any) + process, _ := details["processOutput"].(map[string]any) + switch code := process["exitCode"].(type) { + case json.Number: + value, err := code.Int64() + return value, err == nil + case float64: + return int64(code), code == math.Trunc(code) } - return result + return 0, false } diff --git a/apps/daemon/internal/agent/mcode/tool_observations_test.go b/apps/daemon/internal/agent/mcode/tool_observations_test.go index 703e3cb90..12817a217 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations_test.go +++ b/apps/daemon/internal/agent/mcode/tool_observations_test.go @@ -8,18 +8,32 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestWorkspaceCommandObservationsWaitForArgumentsAndRetainOutcome(t *testing.T) { - for _, status := range []string{"completed", "failed"} { - t.Run(status, func(t *testing.T) { - out := make(chan proto.Envelope, 8) - 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"}) +func observationSession() (*Session, chan proto.Envelope) { + out := make(chan proto.Envelope, 8) + return &Session{ctx: context.Background(), outputContext: context.Background(), req: proto.PromptRequestPayload{RunID: "run"}, opts: launchOptions{Dir: "/environment/workspace"}, + out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}}, out +} + +func TestNativeBashBecomesCommandObservation(t *testing.T) { + for _, test := range []struct { + status string + output map[string]any + exit *int64 + text string + }{ + {"completed", map[string]any{"content": []any{map[string]any{"type": "text", "text": "/environment/workspace"}}, + "details": map[string]any{"processOutput": map[string]any{"exitCode": json.Number("0")}}}, ptr(0), "/environment/workspace"}, + // A failed command reports only prose, which is not parsed for an exit code. + {"failed", map[string]any{"content": []any{map[string]any{"type": "text", "text": "Command exited with code 3"}}}, nil, "Command exited with code 3"}, + } { + t.Run(test.status, func(t *testing.T) { + s, out := observationSession() + _ = s.emitTool(toolUpdate{ID: "call", Name: "bash"}) if len(out) != 0 { t.Fatal("command item emitted before native arguments") } - s.emitTool(toolUpdate{ID: "call", RawInput: map[string]any{"command": "pwd"}}) - s.emitTool(toolUpdate{ID: "call", Status: status, RawOutput: map[string]any{"content": []any{map[string]string{"type": "text", "text": "/workspace"}}}}) - s.emitTool(toolUpdate{ID: "call", Status: status}) + _ = s.emitTool(toolUpdate{ID: "call", RawInput: map[string]any{"command": "pwd"}}) + _ = s.emitTool(toolUpdate{ID: "call", Status: test.status, RawOutput: test.output}) if len(out) != 2 { t.Fatalf("events=%d", len(out)) } @@ -30,20 +44,40 @@ func TestWorkspaceCommandObservationsWaitForArgumentsAndRetainOutcome(t *testing t.Fatal(before) } n := after.Observation - if n == nil || n.Command != "pwd" || n.Cwd == nil || *n.Cwd != "/workspace" || n.Status != status { + output, _ := json.Marshal(test.text) + if n == nil || n.Command != "pwd" || n.Cwd == nil || *n.Cwd != "/environment/workspace" || n.Status != test.status || string(n.Output) != string(output) { t.Fatal(after) } - if string(n.Output) != `"/workspace"` || n.ExitCode != nil || n.DurationMS != nil { - t.Fatal(n) + if (n.ExitCode == nil) != (test.exit == nil) || (n.ExitCode != nil && *n.ExitCode != *test.exit) || n.DurationMS != nil { + t.Fatal("exit code", n.ExitCode) } }) } } -func TestPrivateUtilitiesAreNotInventedPublicFunctionCalls(t *testing.T) { - for _, name := range []string{"mcp__oac_workspace__workspace_read", "skill", "task_query", "task_output", "task_stop", "mcp__unregistered__workspace_bash"} { - if workspaceToolObservation(toolUpdate{Name: name}, "before") != nil { +func TestNativeWebFetchBecomesOpenPageObservation(t *testing.T) { + s, out := observationSession() + _ = s.emitTool(toolUpdate{ID: "call", Name: "web_fetch", RawInput: map[string]any{"url": "https://example.test/doc"}}) + _ = s.emitTool(toolUpdate{ID: "call", Status: "failed", RawOutput: map[string]any{}}) + var calls [2]proto.ToolCallPayload + for i := range calls { + _ = json.Unmarshal((<-out).Payload, &calls[i]) + } + for i, status := range []string{"in_progress", "failed"} { + n := calls[i].Observation + if n == nil || n.Kind != "web_search" || n.Status != status || n.Action == nil || n.Action.Type != "open_page" || n.Action.URL == nil || *n.Action.URL != "https://example.test/doc" { + t.Fatal(calls[i]) + } + } +} + +func TestOtherNativeToolsAreNotInventedPublicItems(t *testing.T) { + s, _ := observationSession() + for _, name := range []string{"read", "write", "edit", "grep", "glob", "todowrite", "skill", "task_query", "task_output", "task_stop"} { + if s.nativeToolObservation(toolUpdate{Name: name, RawInput: map[string]any{"command": "x", "url": "x"}}, "before") != nil { t.Fatal(name) } } } + +func ptr(value int64) *int64 { return &value } diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go index e221bd595..ac35c4efa 100644 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ b/apps/daemon/internal/agent/mcode/unsupported.go @@ -2,15 +2,9 @@ package mcode import ( "context" - "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { - return fmt.Errorf("%w: native public function tools are not qualified", agent.ErrUnsupportedOperation) -} - func (s *executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported } diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go index eb0609bb6..05a2afb0d 100644 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ b/apps/daemon/internal/agent/mcode/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,8 +24,6 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - var turn *Session - check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index b5bbd2899..334d6c782 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -1,38 +1,33 @@ package mcode import ( - "encoding/json" "fmt" + "maps" "os" "path/filepath" "runtime" + "slices" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { - Binary, Node, Bridge, Directory, Network, Scratch string - AllowedDomains []string + Binary, Node, Companion, Directory string } -func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy) (WorkspaceConfig, error) { - c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), - Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} - if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { - return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") +func ConfigureLocal(binary, node, companion, workspace string) (WorkspaceConfig, error) { + c := WorkspaceConfig{Binary: binary, Node: node, Companion: companion, Directory: workspace} + if runtime.GOOS == "windows" { + return c, fmt.Errorf("mcode: native execution requires Linux or macOS") } - if network.Validate() != nil { - return c, fmt.Errorf("mcode: explicit workspace network policy is required") - } - for _, path := range []string{binary, node, bridge, root, workspace} { + for _, path := range []string{binary, node, companion, workspace} { if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") } } - for _, path := range []string{binary, node, bridge} { + for _, path := range []string{binary, node} { info, err := os.Stat(path) if err != nil || !info.Mode().IsRegular() { return c, fmt.Errorf("mcode: runtime program unavailable") @@ -42,31 +37,28 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn if err != nil || !bound.IsDir() { return c, fmt.Errorf("mcode: workspace directory unavailable") } - - if err := os.MkdirAll(c.Scratch, 0700); err != nil { - return c, err - } return c, nil } func prepareWorkspaceOptions(runtimeRoot string, 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.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 || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } - servers, err := runtimeMCP(req) + values, err := localworkspace.ReadOptionalToolEnvironment() if err != nil { return launchOptions{}, err } + // A disabled web_search control removes web_fetch as well; without the control the native default applies. + searchDisabled := req.ExecutionControls != nil && req.ExecutionControls.WebSearch != nil && req.ExecutionControls.WebSearch.Mode == "disabled" + profile := &workspaceProfile{WebFetch: !searchDisabled} + for _, key := range slices.Sorted(maps.Keys(values)) { + profile.ToolEnv = append(profile.ToolEnv, key+"="+values[key]) + } // Reuse public option validation and private Session state provisioning. - // The native process, ACP Session and workspace tools share the declared cwd. + // The native process runs in the declared workspace. private := req private.LocalEnvironment, private.DisableExecutionEnvironment = nil, true - // Public declarations have already been resolved into the transient ACP map. - private.MCPHTTPServers = nil - opts, err := prepareOptions(runtimeRoot, private) + opts, err := prepareOptions(runtimeRoot, private, profile) if err != nil { return opts, err } @@ -90,73 +82,5 @@ func prepareWorkspaceOptions(runtimeRoot string, c WorkspaceConfig, req proto.Pr } } } - - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) - if err != nil { - return opts, err - } - var config map[string]any - if err = json.Unmarshal(raw, &config); err != nil { - return opts, err - } - config["permissionMode"] = "bypassPermissions" - config["sandbox"] = map[string]bool{"enabled": false} - if len(req.LocalEnvironment.Skills) > 0 { - selected := config["agents"].(map[string]any)["default"].(map[string]any) - names := make([]string, 0, len(req.LocalEnvironment.Skills)) - for _, skill := range req.LocalEnvironment.Skills { - names = append(names, skill.Metadata.Name) - } - selected["skills"] = names - for _, key := range []string{"tools", "builtinTools"} { - selected[key] = append(selected[key].([]any), "skill") - } - } - raw, err = json.Marshal(config) - if err != nil { - return opts, err - } - if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { - return opts, err - } - profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} - - profile["workspace"] = c.Directory - { - file, err := localworkspace.ToolEnvironmentFile() - if err != nil { - return opts, err - } - if file != "" { - profile["toolEnvFile"] = file - } - } - - raw, err = json.Marshal(profile) - if err != nil { - return opts, err - } - path := filepath.Join(opts.DataDir, "workspace-profile.json") - if err = os.WriteFile(path, raw, 0600); err != nil { - return opts, err - } - opts.MCP = []map[string]any{{"name": "oac_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} - opts.MCP = append(opts.MCP, servers...) - if !req.DisableSubagents { - // This native data directory belongs to one public Session and its - // descendants. ACP's ephemeral server map otherwise covers only root. - configured := map[string]any{} - for _, server := range opts.MCP[:1] { - name, _ := server["name"].(string) - configured[name] = map[string]any{"type": "stdio", "command": server["command"], "args": server["args"], "env": map[string]string{}, "enabled": true} - } - raw, err := json.Marshal(map[string]any{"mcpServers": configured}) - if err != nil { - return opts, err - } - if err := os.WriteFile(filepath.Join(opts.DataDir, "mcp.json"), raw, 0o600); err != nil { - return opts, err - } - } return opts, nil } diff --git a/apps/daemon/internal/agent/mcode/workspace_config_test.go b/apps/daemon/internal/agent/mcode/workspace_config_test.go new file mode 100644 index 000000000..3e1c6b1f5 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/workspace_config_test.go @@ -0,0 +1,73 @@ +package mcode + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func readNativeCapabilities(t *testing.T, dataDir string) (map[string]any, map[string]any) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dataDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatal(err) + } + return config, config["agents"].(map[string]any)["default"].(map[string]any) +} + +func TestWorkspaceRunsNativeToolsWithoutExtraServers(t *testing.T) { + native := []any{"read", "write", "edit", "bash", "grep", "glob", "todowrite"} + for _, test := range []struct { + name string + subagents, disabled bool + tools []any + }{ + {"single agent", false, false, append(slices.Clone(native), "web_fetch")}, + {"subagents", true, false, append(slices.Clone(native), "web_fetch")}, + {"search disabled", true, true, native}, + } { + t.Run(test.name, func(t *testing.T) { + c, req, _ := workspaceFixture(t) + if test.subagents { + dir := t.TempDir() + for _, name := range []string{"node", "subagent-snapshot.mjs"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0700); err != nil { + t.Fatal(err) + } + } + t.Setenv("OAC_RUNTIME_MCODE_NODE", filepath.Join(dir, "node")) + t.Setenv("OAC_RUNTIME_MCODE_COMPANION", dir) + limit := 2 + req.DisableSubagents, req.MaxConcurrentSubagents = false, &limit + } + if test.disabled { + req.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "medium"}} + } + opts, err := prepareWorkspaceOptions(testStateRoot(t), c, req) + if err != nil { + t.Fatal(err) + } + config, capabilities := readNativeCapabilities(t, opts.DataDir) + features := capabilities["features"].(map[string]any) + if _, ok := capabilities["skills"]; ok || !reflect.DeepEqual(capabilities["tools"], test.tools) || features["delegation"] != test.subagents || + config["permissionMode"] != "bypassPermissions" || len(opts.MCP) != 0 || opts.Dir != c.Directory { + t.Fatalf("unexpected native configuration: %v %v", config, opts.MCP) + } + if !slices.Contains(opts.Env, "OAC_RUNTIME_MCODE_BACKGROUND_BASH=off") { + t.Fatal("background bash is not disabled") + } + if _, err := os.Stat(filepath.Join(opts.DataDir, "mcp.json")); !os.IsNotExist(err) { + t.Fatalf("adapter wrote a native MCP file: %v", err) + } + }) + } +} diff --git a/apps/daemon/internal/agent/mcode/workspace_native_test.go b/apps/daemon/internal/agent/mcode/workspace_native_test.go new file mode 100644 index 000000000..d9639eed1 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/workspace_native_test.go @@ -0,0 +1,186 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// This opt-in test makes real model calls with the native tools in a workspace. +// It needs the built companion (OAC_RUNTIME_MCODE_COMPANION, OAC_RUNTIME_MCODE_NODE), the native CLI +// (OAC_RUNTIME_MCODE_BIN) and private provider options (OAC_TEST_MCODE_REAL_OPTIONS). +func TestNativeMCodeWorkspaceTools(t *testing.T) { + binary, node, companion, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_COMPANION"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if binary == "" || node == "" || companion == "" || options == "" { + t.Skip("native executable, companion and private provider options required") + } + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal("private provider options unavailable") + } + type result struct { + workspace, content string + calls map[string][]proto.ToolCallPayload + events map[string]int + } + // run executes one Turn; a nil config selects the none placement. + run := func(t *testing.T, c *WorkspaceConfig, search *proto.WebSearchControl, prompt string) result { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 6*time.Minute) + defer cancel() + workspace := t.TempDir() + if c != nil { + c.Directory = workspace + } + req := testRequest(t) + req.RunID, req.Input = "", nil + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private provider options") + } + limit := 2 + req.DisableSubagents, req.MaxConcurrentSubagents = false, &limit + if c != nil { + req.DisableExecutionEnvironment = false + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: workspace} + } + if search != nil { + req.ExecutionControls = &proto.ExecutionControls{WebSearch: search} + } + if err := os.WriteFile(filepath.Join(workspace, "notes.txt"), []byte("alpha\n"), 0600); err != nil { + t.Fatal(err) + } + skill := filepath.Join(workspace, ".claude", "skills", "probe-phrase") + if err := os.MkdirAll(skill, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("---\nname: probe-phrase\ndescription: Gives the probe phrase.\n---\nThe probe phrase is violet-anchor-58.\n"), 0600); err != nil { + t.Fatal(err) + } + value, err := NewExecutorFactory(testStateRoot(t), c)(ctx, req) + if err != nil { + t.Fatal("native Executor preparation failed:", err) + } + e := value.(*executor) + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + if e.Close(cleanup) != nil { + t.Error("native owner cleanup failed") + } + }() + out := make(chan proto.Envelope, 1024) + turn, err := e.StartTurn(ctx, "turn", proto.TextInput(prompt), out) + if err != nil { + t.Fatal(err) + } + r := result{workspace: workspace, calls: map[string][]proto.ToolCallPayload{}, events: map[string]int{}} + for event := range out { + r.events[event.Type]++ + switch event.Type { + case proto.TypeError: + t.Fatalf("native Turn reported an error: %s", event.Payload) + case proto.TypeToolCall: + var call proto.ToolCallPayload + if json.Unmarshal(event.Payload, &call) != nil { + t.Fatal("invalid tool call") + } + r.calls[call.ID] = append(r.calls[call.ID], call) + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(event.Payload, &done) + r.content = done.Content + } + } + if _, err := turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + return r + } + workspace := &WorkspaceConfig{Binary: binary, Node: node, Companion: companion} + // finished returns the final observation of each command call, keyed by command text. + finished := func(calls map[string][]proto.ToolCallPayload) map[string]*proto.ToolObservation { + result := map[string]*proto.ToolObservation{} + for _, stages := range calls { + if last := stages[len(stages)-1]; last.Stage == "after" && last.Observation != nil && last.Observation.Kind == "command" { + result[last.Observation.Command] = last.Observation + } + } + return result + } + t.Run("files and commands", func(t *testing.T) { + r := run(t, workspace, nil, "Use your tools. 1) Read notes.txt. 2) Edit notes.txt, replacing alpha with beta. 3) Run exactly this bash command: echo bash-ok > out.txt. 4) Run exactly this bash command: sh -c 'exit 3'. Then reply DONE.") + notes, _ := os.ReadFile(filepath.Join(r.workspace, "notes.txt")) + out, _ := os.ReadFile(filepath.Join(r.workspace, "out.txt")) + if strings.TrimSpace(string(notes)) != "beta" || strings.TrimSpace(string(out)) != "bash-ok" { + t.Fatalf("workspace not changed by native tools: %q %q", notes, out) + } + commands := finished(r.calls) + write, failed := commands["echo bash-ok > out.txt"], commands["sh -c 'exit 3'"] + if write == nil || write.Status != "completed" || write.ExitCode == nil || *write.ExitCode != 0 || write.Cwd == nil || *write.Cwd != r.workspace { + t.Fatalf("command observation: %+v", write) + } + if failed == nil || failed.Status != "failed" || failed.ExitCode != nil || failed.Output == nil { + t.Fatalf("failed command observation: %+v", failed) + } + }) + t.Run("bash stays in the foreground", func(t *testing.T) { + r := run(t, workspace, nil, "Use bash. 1) Run exactly this command and wait for it: sleep 20; echo slow-done. 2) Call bash with run_in_background set to true and the command: touch bg-ran. Report both tool replies.") + slow := finished(r.calls)["sleep 20; echo slow-done"] + if slow == nil || slow.Status != "completed" || !strings.Contains(string(slow.Output), "slow-done") { + t.Fatalf("slow command was not completed in the foreground: %+v", slow) + } + background := false + for _, stages := range r.calls { + if stages[0].Args["run_in_background"] == true { + background = true + if last := stages[len(stages)-1]; last.Observation == nil || last.Observation.Status != "failed" { + t.Fatalf("background bash was not refused: %+v", last.Observation) + } + } + } + time.Sleep(3 * time.Second) + if _, err := os.Stat(filepath.Join(r.workspace, "bg-ran")); err == nil || !background { + t.Fatal("background bash ran or was never requested") + } + }) + t.Run("Subagents use the native tools", func(t *testing.T) { + r := run(t, workspace, nil, "Use the task tool to start one worker Subagent with this instruction: run the bash command `echo child-ok > child.txt`, then reply done. Wait for its result, then reply DONE.") + child, _ := os.ReadFile(filepath.Join(r.workspace, "child.txt")) + if strings.TrimSpace(string(child)) != "child-ok" || r.events[proto.TypeSubagentIdentity] == 0 || r.events[proto.TypeSubagentItem] == 0 { + t.Fatalf("Subagent did not run in the workspace: %q %v", child, r.events) + } + }) + t.Run("workspace Skills load natively", func(t *testing.T) { + r := run(t, workspace, nil, "Use the probe-phrase Skill, then reply with the probe phrase only.") + if !strings.Contains(r.content, "violet-anchor-58") { + t.Fatalf("workspace Skill was not loaded: %q", r.content) + } + }) + t.Run("web search control", func(t *testing.T) { + const prompt = "Without calling any tool, list the exact names of every tool you can call, comma separated." + tools := run(t, workspace, nil, prompt).content + disabled := run(t, workspace, &proto.WebSearchControl{Mode: "disabled", ContextSize: "medium"}, prompt).content + if !strings.Contains(tools, "web_fetch") || !strings.Contains(tools, "bash") || strings.Contains(disabled, "web_fetch") || !strings.Contains(disabled, "bash") { + t.Fatalf("tool inventories: %q / %q", tools, disabled) + } + for _, name := range []string{"website_deploy", "ask_user", "mavis", "web_search"} { + if strings.Contains(tools, name) { + t.Fatalf("%s is available: %q", name, tools) + } + } + }) + t.Run("no Environment has no builtin tools", func(t *testing.T) { + tools := run(t, nil, nil, "Without calling any tool, list the exact names of every tool you can call, comma separated.").content + for _, name := range []string{"bash", "read", "write", "edit", "web_fetch"} { + if strings.Contains(tools, name) { + t.Fatalf("%s is available: %q", name, tools) + } + } + }) +} diff --git a/apps/daemon/internal/agent/mcode/workspace_network_test.go b/apps/daemon/internal/agent/mcode/workspace_network_test.go deleted file mode 100644 index 57aed9044..000000000 --- a/apps/daemon/internal/agent/mcode/workspace_network_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package mcode - -import "testing" - -func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { - for _, access := range []string{"disabled", "restricted"} { - c, req, _ := workspaceFixture(t) - c.Network = access - req.LocalEnvironment.NetworkAccess = access - if _, err := prepareWorkspaceOptions(testStateRoot(t), 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 7f44d672a..5621f588a 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()}) + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(c.Companion, "check.mjs")}, Env: executionEnvironment()}) if err != nil { return err } @@ -40,7 +40,7 @@ func ValidateWorkspaceReadiness(raw []byte) error { Protocol int `json:"protocol"` Native, Source string } - if len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 2 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { + if len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 3 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { return fmt.Errorf("mcode: workspace companion check failed") } return nil diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness_test.go b/apps/daemon/internal/agent/mcode/workspace_readiness_test.go index 6fca32fe8..910a4d78d 100644 --- a/apps/daemon/internal/agent/mcode/workspace_readiness_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_readiness_test.go @@ -14,7 +14,7 @@ func TestWorkspaceReadinessRejectsOldCleanupContract(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("MiniMax adapter supports Linux and macOS") } - for _, protocol := range []int{1, 2} { + for _, protocol := range []int{2, 3} { t.Run(fmt.Sprint(protocol), func(t *testing.T) { dir := t.TempDir() node := filepath.Join(dir, "node") @@ -22,8 +22,8 @@ func TestWorkspaceReadinessRejectsOldCleanupContract(t *testing.T) { if err := os.WriteFile(node, []byte(body), 0700); err != nil { t.Fatal(err) } - err := CheckWorkspace(context.Background(), WorkspaceConfig{Node: node, Bridge: filepath.Join(dir, "bridge.mjs")}) - if (err == nil) != (protocol == 2) { + err := CheckWorkspace(context.Background(), WorkspaceConfig{Node: node, Companion: dir}) + if (err == nil) != (protocol == 3) { t.Fatalf("protocol %d readiness: %v", protocol, err) } }) @@ -32,9 +32,9 @@ func TestWorkspaceReadinessRejectsOldCleanupContract(t *testing.T) { func TestValidateWorkspaceReadinessRejectsInvalidDescriptors(t *testing.T) { for _, raw := range []string{"", "null", "{", strings.Repeat(" ", 4097), - `{"protocol":1,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, - `{"protocol":2,"native":"0.4.11","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, - `{"protocol":2,"native":"0.4.12","source":"other"}`} { + `{"protocol":2,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, + `{"protocol":3,"native":"0.4.11","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, + `{"protocol":3,"native":"0.4.12","source":"other"}`} { if err := ValidateWorkspaceReadiness([]byte(raw)); err == nil || err.Error() != "mcode: workspace companion check failed" { t.Fatal("invalid descriptor accepted or exposed", err) } diff --git a/apps/daemon/internal/agent/mcode/workspace_skills_test.go b/apps/daemon/internal/agent/mcode/workspace_skills_test.go index 1126eaac9..c063c6553 100644 --- a/apps/daemon/internal/agent/mcode/workspace_skills_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_skills_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "os" "path/filepath" - "reflect" + "slices" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" @@ -42,17 +42,16 @@ func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { } var config struct { Agents map[string]struct { - Skills, Tools, BuiltinTools []string + Skills, Tools []string } } if err := json.Unmarshal(raw, &config); err != nil { t.Fatal(err) } selected := config.Agents["default"] - if !reflect.DeepEqual(selected.Skills, []string{"proof"}) || - !reflect.DeepEqual(selected.Tools, []string{"skill"}) || - !reflect.DeepEqual(selected.BuiltinTools, []string{"skill"}) { - t.Fatalf("native selection lost the installed Skill: %+v", selected) + // Skill names are not native IDs, so the selection stays open and no tool list carries "skill". + if selected.Skills != nil || slices.Contains(selected.Tools, "skill") || !slices.Contains(selected.Tools, "bash") { + t.Fatalf("native selection does not leave the installed Skill available: %+v", selected) } } } diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index a59eb8091..a1f5413f3 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -12,7 +12,7 @@ import ( // MCPBinding is the Runtime's transient effective declaration. Adapters project // it into their native configuration without resolving credentials or packages. -// Never serialize or log bindings: headers and bearer values may be confidential. +// Never serialize or log bindings: bearer values are confidential. type MCPBinding struct { ServerLabel string ConnectionOrigin string @@ -25,6 +25,8 @@ type MCPBinding struct { HTTPHeaders map[string]string // Stdio retains the installed package identity for the fixed Runtime launcher. Stdio *proto.EnvironmentMCP + // Process is a public stdio declaration, started natively by the Harness. + Process *proto.MCPStdio } // ResolveMCPBindings combines public declarations with the frozen installation. @@ -38,7 +40,16 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { if err := server.ValidateConnectionOrigin(req); err != nil { return nil, err } - item := MCPBinding{ServerLabel: server.ServerLabel, ConnectionOrigin: server.ConnectionOrigin, CredentialAuthority: "none", Transport: "http", ServerURL: server.ServerURL, Required: server.Required, BearerToken: server.BearerToken} + transport := "http" + if server.Stdio != nil { + transport = "stdio" + } + item := MCPBinding{ServerLabel: server.ServerLabel, ConnectionOrigin: server.ConnectionOrigin, CredentialAuthority: "none", Transport: transport, ServerURL: server.ServerURL, Required: server.Required, BearerToken: server.BearerToken, HTTPHeaders: maps.Clone(server.Headers)} + if server.Stdio != nil { + process := *server.Stdio + process.Args, process.EnvVars = slices.Clone(process.Args), slices.Clone(process.EnvVars) + item.Process = &process + } if server.AllowedTools != nil { names := append([]string{}, (*server.AllowedTools)...) item.AllowedTools = &names @@ -50,9 +61,6 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { } } if local := req.LocalEnvironment; local != nil && len(local.MCP) > 0 { - if req.DisableExecutionEnvironment || local.NetworkAccess != "enabled" { - return nil, errors.New("environment MCP requires an enabled workspace network") - } for _, installed := range local.MCP { declaration := installed.Server item := MCPBinding{ServerLabel: declaration.Name, ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: declaration.Type, ServerURL: declaration.URL, BearerToken: installed.BearerToken, HTTPHeaders: maps.Clone(declaration.HTTPHeaders)} @@ -82,26 +90,29 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { value := *item.BearerToken item.BearerToken = &value } + if item.AllowedTools != nil { + for _, name := range *item.AllowedTools { + if name == "" || strings.TrimSpace(name) != name { + return nil, errors.New("invalid MCP tool allowlist") + } + } + } switch item.Transport { case "stdio": - if item.Stdio == nil || item.BearerToken != nil || len(item.HTTPHeaders) > 0 || item.ServerURL != "" { - return nil, errors.New("invalid installed MCP transport") + if (item.Stdio == nil) == (item.Process == nil) || item.BearerToken != nil || len(item.HTTPHeaders) > 0 || item.ServerURL != "" { + return nil, errors.New("invalid MCP stdio transport") + } + if process := item.Process; process != nil && !process.Valid() { + return nil, errors.New("invalid MCP stdio declaration") } case "http": endpoint, err := url.Parse(item.ServerURL) - if err != nil || endpoint.Hostname() == "" || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" || (item.ConnectionOrigin == "service" && (endpoint.RawQuery != "" || endpoint.ForceQuery)) { + if err != nil || endpoint.Hostname() == "" || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" { return nil, errors.New("invalid MCP endpoint") } if item.BearerToken != nil && (endpoint.Scheme != "https" || !ValidMCPHTTPBearerToken(*item.BearerToken)) { return nil, errors.New("invalid HTTPS MCP bearer credential") } - if item.AllowedTools != nil { - for _, name := range *item.AllowedTools { - if name == "" || strings.TrimSpace(name) != name { - return nil, errors.New("invalid MCP tool allowlist") - } - } - } default: return nil, errors.New("unsupported MCP transport") } diff --git a/apps/daemon/internal/agent/mcp_binding_test.go b/apps/daemon/internal/agent/mcp_binding_test.go index e1a020ede..c205380ed 100644 --- a/apps/daemon/internal/agent/mcp_binding_test.go +++ b/apps/daemon/internal/agent/mcp_binding_test.go @@ -1,6 +1,7 @@ package agent import ( + "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -25,7 +26,7 @@ func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { t.Fatal("binding retained mutable credential pointer") } } - local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ + local := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{ {Server: agentplugin.MCPServer{Name: "stdio", Type: "stdio", Command: "node", Args: []string{"tool.js"}}, PackageRoot: "plugins/proof", InstallationRoot: "/private/installed"}, {Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp", HTTPHeaders: map[string]string{"X-Selected": "literal"}}, BearerToken: &token}, }} @@ -57,13 +58,11 @@ func TestMCPBindingsDistinguishAbsentAndEmptyProfile(t *testing.T) { func TestMCPBindingsRejectRelocationAndAmbiguousInstallation(t *testing.T) { public := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}}} + local := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}}} for _, req := range []proto.PromptRequestPayload{ {MCPHTTPServers: &public, LocalEnvironment: local}, - {DisableExecutionEnvironment: true, LocalEnvironment: local}, - {LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: append(local.MCP, local.MCP[0])}}, - {LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "disabled", MCP: local.MCP}}, - {LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp", BearerTokenEnvVar: "MISSING"}}}}}, + {LocalEnvironment: &proto.LocalEnvironment{MCP: append(local.MCP, local.MCP[0])}}, + {LocalEnvironment: &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp", BearerTokenEnvVar: "MISSING"}}}}}, } { if _, err := ResolveMCPBindings(req); err == nil { t.Fatal("unsupported authority accepted") @@ -75,7 +74,7 @@ func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { token := "selected-vault-canary" names := []string{"prove"} public := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", BearerToken: &token, AllowedTools: &names, Required: true}} - req := proto.PromptRequestPayload{MCPHTTPServers: &public, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} + req := proto.PromptRequestPayload{MCPHTTPServers: &public, LocalEnvironment: &proto.LocalEnvironment{}} got, err := ResolveMCPBindings(req) if err != nil || len(got) != 1 { t.Fatal("environment binding unavailable", err) @@ -104,3 +103,27 @@ func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { t.Fatal("unprepared environment accepted") } } + +func TestMCPBindingsPublicStdio(t *testing.T) { + stdio := proto.MCPStdio{Command: "run", Args: []string{"-x"}, Cwd: "/work", EnvVars: []string{"TOKEN"}} + resolve := func(origin string, stdio proto.MCPStdio) ([]MCPBinding, error) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: origin, ServerLabel: "local", Stdio: &stdio}} + return ResolveMCPBindings(proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers}) + } + got, err := resolve("environment", stdio) + if err != nil || len(got) != 1 || got[0].Transport != "stdio" || got[0].Stdio != nil || !reflect.DeepEqual(*got[0].Process, stdio) { + t.Fatal("public stdio binding unavailable", got, err) + } + got[0].Process.Args[0] = "mutated" + if stdio.Args[0] != "-x" { + t.Fatal("binding shares the declaration") + } + for name, invalid := range map[string]proto.MCPStdio{"command": {Command: " "}, "variable": {Command: "run", EnvVars: []string{"1=2"}}} { + if _, err := resolve("environment", invalid); err == nil { + t.Fatal("invalid stdio declaration accepted: " + name) + } + } + if _, err := resolve("service", stdio); err == nil { + t.Fatal("service-origin stdio accepted") + } +} diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 3d484d6dc..04ffc9420 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -45,7 +45,25 @@ func TestRegistryRegisterRejectsFactoriesForUnavailableRuntime(t *testing.T) { t.Fatal("rejected runtime changed registry") } }() - registry.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, Executor: executor}) + registry.Register(agent.Declaration{Declaration: harnessconfig.Declaration{Kind: "k"}}, agent.Runtime{Info: info, Executor: executor}) +} + +func TestRegistryRegisterRejectsWidenedDiscovery(t *testing.T) { + declared := prototest.Capabilities(proto.AgentKindCapabilities{}) + executor := func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, nil } + for name, runtime := range map[string]agent.Runtime{ + "capability": {Info: proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported})}}, + "executor": {Info: proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: declared}, Executor: executor}, + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("discovery widened the declaration") + } + }() + agent.NewRegistry().Register(agent.Declaration{Declaration: harnessconfig.Declaration{Kind: "k", Capabilities: declared}}, runtime) + }) + } } func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { diff --git a/apps/daemon/internal/cli/agent_discovery.go b/apps/daemon/internal/cli/agent_discovery.go index b61818544..06c88d697 100644 --- a/apps/daemon/internal/cli/agent_discovery.go +++ b/apps/daemon/internal/cli/agent_discovery.go @@ -1,20 +1,17 @@ package cli import ( + "bytes" "context" "fmt" + "sync" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) -var harnessDeclarations = []agent.Declaration{codex.Declaration, mcode.Declaration, claudesdk.Declaration} - type discoveredHarness struct { declaration agent.Declaration runtime agent.Runtime @@ -33,22 +30,37 @@ func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, d if err != nil { return nil, err } - var out agentCLIDiscovery - available := false - for _, declaration := range declarations { - if rc.installedKinds != nil && !rc.installedKinds[declaration.Info.Kind] { + // Preflights are independent and sit on every cold start, so they run + // concurrently; their output is replayed in declaration order. + type probe struct { + runtime *agent.Runtime + stdout, stderr bytes.Buffer + } + probes := make([]probe, len(declarations)) + var running sync.WaitGroup + for i, declaration := range declarations { + if rc.installedKinds != nil && !rc.installedKinds[declaration.Kind] { continue } - started := time.Now() - runtime := declaration.Discover(parent, agent.DiscoveryOptions{Profile: profile, StateRoot: stateRoot, RuntimeRoot: runtimeRoot, Stdout: rc.stdout, Stderr: rc.stderr}, declaration.Info) - 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 { + running.Go(func() { + p := &probes[i] + started := time.Now() + p.runtime = declaration.Discover(parent, agent.DiscoveryOptions{Profile: profile, StateRoot: stateRoot, RuntimeRoot: runtimeRoot, Stdout: &p.stdout, Stderr: &p.stderr}, declaration.Info()) + obslog.Ctx(parent).Info("runtime startup stage", "stage", "harness_discovery", "harness_kind", declaration.Kind, "duration_ms", float64(time.Since(started))/float64(time.Millisecond), "available", p.runtime != nil && p.runtime.Info.Available) + }) + } + running.Wait() + var out agentCLIDiscovery + available := false + for i := range probes { + p := &probes[i] + _, _ = rc.stdout.Write(p.stdout.Bytes()) + _, _ = rc.stderr.Write(p.stderr.Bytes()) + if p.runtime == nil { continue } - out = append(out, discoveredHarness{declaration, *runtime}) - available = available || runtime.Info.Available + out = append(out, discoveredHarness{declarations[i], *p.runtime}) + available = available || p.runtime.Info.Available } if err := parent.Err(); err != nil { return out, err diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go index 283a34291..080ae43a3 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -87,7 +87,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) defer cancel() 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}} + request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: "registered-acceptance", AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{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}`)}} } diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index 3b26fc80d..ab9419878 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -316,6 +316,16 @@ func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile // conn.Recv(), heartbeats every boot.HeartbeatInterval(), and a // confirmed router.Shutdown before returning ownership to the reconnect loop. // Failed cleanup keeps this exact Router alive, including after a shutdown signal. +// advertisedKinds withdraws workspace export when this host cannot provide it now. +func advertisedKinds(registry *agent.Registry, canExport bool) []proto.SupportedAgentKind { + kinds := registry.SupportedAgentKinds() + for i := range kinds { + caps := &kinds[i].Capabilities + agent.Narrow(&caps.WorkspaceOutputExport, canExport && caps.LocalEnvironment.IsSupported() && caps.WorkspaceReadPreparation.IsSupported()) + } + return kinds +} + func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.Registry, boot *transport.BootstrapResponse, agentCLIs agentCLIDiscovery) error { local, err := localworkspace.Load() if err != nil { @@ -336,11 +346,7 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R }() conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { - kinds := registry.SupportedAgentKinds() - for i := range kinds { - caps := &kinds[i].Capabilities - caps.WorkspaceOutputExport = proto.CapabilityFromBool(local.CanExport() && caps.LocalEnvironment.IsSupported() && caps.WorkspaceReadPreparation.IsSupported()) - } + kinds := advertisedKinds(registry, local.CanExport()) return proto.HeartbeatPayload{ Timestamp: time.Now().Unix(), ActiveRequests: router.ActiveRuns(), diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index ba6192874..e84fd7dd2 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -145,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", DisableExecutionEnvironment: true}}) + Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "cleanup", DisableExecutionEnvironment: true}}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index b661092e0..9cf2f0ca0 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -60,18 +60,12 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { return errors.New("connect: enrollment does not match the Runtime workspace") } - for key, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, - "OAC_RUNTIME_SESSION_ID": bound.SessionID, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - } { + identity := map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bound.SessionID} + for key, value := range identity { if previous := os.Getenv(key); previous != "" && previous != value { - return errors.New("connect: conflicting Runtime identity or policy") + return errors.New("connect: conflicting Runtime identity") } } - if domains := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); domains != "" && domains != "[]" { - return errors.New("connect: conflicting Runtime network domains") - } capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") if capabilityDirectory == "" { capabilityDirectory = localworkspace.CapabilityDirectory @@ -83,7 +77,7 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if err = saveEnvironmentBinding(root, want); err != nil { return err } - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bound.SessionID, "OAC_RUNTIME_NETWORK_ACCESS": "enabled"} { + for key, value := range identity { if err = os.Setenv(key, value); err != nil { return errors.New("connect: Runtime identity configuration failed") } diff --git a/apps/daemon/internal/cli/connect_environment_binding_test.go b/apps/daemon/internal/cli/connect_environment_binding_test.go index 0f2b3ac68..bf9e91728 100644 --- a/apps/daemon/internal/cli/connect_environment_binding_test.go +++ b/apps/daemon/internal/cli/connect_environment_binding_test.go @@ -23,7 +23,7 @@ func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string } t.Setenv("OAC_RUNTIME_HOME", root) t.Setenv("OAC_RUNTIME_WORKSPACE", workspace) - for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID", "OAC_RUNTIME_NETWORK_ACCESS", "OAC_RUNTIME_ALLOWED_DOMAINS"} { + for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID"} { t.Setenv(name, "") } return root, workspace, credential, environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"} diff --git a/apps/daemon/internal/cli/connect_heartbeat_test.go b/apps/daemon/internal/cli/connect_heartbeat_test.go new file mode 100644 index 000000000..cc29ac3d4 --- /dev/null +++ b/apps/daemon/internal/cli/connect_heartbeat_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "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" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" +) + +func TestHeartbeatNeverAdvertisesUndeclaredExport(t *testing.T) { + local := proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported} + exporting := local + exporting.WorkspaceOutputExport = proto.CapabilitySupported + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "declared", Available: true, Capabilities: prototest.Capabilities(exporting)}, harnessconfig.Configuration{}) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "undeclared", Available: true, Capabilities: prototest.Capabilities(local)}, harnessconfig.Configuration{}) + for _, canExport := range []bool{false, true} { + for _, kind := range advertisedKinds(registry, canExport) { + if got, want := kind.Capabilities.WorkspaceOutputExport.IsSupported(), canExport && kind.Kind == "declared"; got != want { + t.Fatalf("%s with host export %v advertised export %v", kind.Kind, canExport, got) + } + } + } +} diff --git a/apps/daemon/internal/cli/connect_suspend.go b/apps/daemon/internal/cli/connect_suspend.go index 40e918092..5fb40fb31 100644 --- a/apps/daemon/internal/cli/connect_suspend.go +++ b/apps/daemon/internal/cli/connect_suspend.go @@ -152,11 +152,7 @@ func (s *suspendedRouter) reconnectSuspension(ctx context.Context, dial transpor func (s *suspendedRouter) heartbeats(ctx context.Context, conn *transport.Conn, boot *transport.BootstrapResponse, discovery agentCLIDiscovery) { conn.StartHeartbeats(ctx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { - kinds := s.registry.SupportedAgentKinds() - for i := range kinds { - caps := &kinds[i].Capabilities - caps.WorkspaceOutputExport = proto.CapabilityFromBool(s.local.CanExport() && caps.LocalEnvironment.IsSupported() && caps.WorkspaceReadPreparation.IsSupported()) - } + kinds := advertisedKinds(s.registry, s.local.CanExport()) return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, SupportedAgentKinds: kinds} }, obslog.Bg()) } diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go index 6d92648e2..34cf456c8 100644 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -347,8 +347,6 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", t.TempDir()) t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", environment) t.Setenv("OAC_RUNTIME_SESSION_ID", session) - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", "disabled") - t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", "") peers := make(chan *websocket.Conn, 2) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) @@ -385,7 +383,7 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test t.Fatal("no connection") } defer peer.Close() - preparation, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-" + session, LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}}) + preparation, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: session, LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/harness_declarations.go b/apps/daemon/internal/cli/harness_declarations.go new file mode 100644 index 000000000..4710bc764 --- /dev/null +++ b/apps/daemon/internal/cli/harness_declarations.go @@ -0,0 +1,12 @@ +// Code generated by scripts/generate-harness-catalog.py; DO NOT EDIT. +package cli + +import ( + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" +) + +// harnessDeclarations binds every built-in Harness declaration to its Runtime adapter. +var harnessDeclarations = []agent.Declaration{claudesdk.Declaration, codex.Declaration, mcode.Declaration} diff --git a/apps/daemon/internal/cli/native_bundle.go b/apps/daemon/internal/cli/native_bundle.go index 3f2126ce4..ff5a7fad1 100644 --- a/apps/daemon/internal/cli/native_bundle.go +++ b/apps/daemon/internal/cli/native_bundle.go @@ -84,8 +84,6 @@ func validBundlePath(name string) bool { return name != "." && path.Clean(name) == name && !strings.ContainsAny(name, "\\\x00\r\n:") && filepath.IsLocal(filepath.FromSlash(name)) && name != ".oac-install.json" } -func nativeComponentRoot(root, name string) string { return filepath.Join(root, "components", name) } - func componentReceipt(root string) (nativeComponent, error) { var c nativeComponent err := readNativeJSON(filepath.Join(root, ".oac-install.json"), &c) @@ -130,35 +128,90 @@ func checkComponentFiles(ctx context.Context, directory string, c nativeComponen return nil } -// A complete component is published once. An interruption cannot turn a partial -// copy into an installation; a subsequent run can reuse an already-published one. -func installNativeComponent(ctx context.Context, source, root, name string, expected nativeComponent) error { - dest := nativeComponentRoot(root, name) +// verifyNativeComponent checks a stored component against its receipt and, when the distribution +// is at hand, against the distribution's manifest. +func verifyNativeComponent(ctx context.Context, dir, name string, expected *nativeComponent) error { + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("shared component %s is missing from %s; run install again", name, filepath.Dir(dir)) + } + remedy := "stop the installations that use it, delete " + dir + " (" + nativeDeleteCommand(dir) + ") and run install again" + if err != nil || !info.IsDir() || !ownedByCurrentUser(info) { + return fmt.Errorf("shared component %s is not a directory of this account; %s", name, remedy) + } + c, err := componentReceipt(dir) + if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 { + return fmt.Errorf("shared component %s is missing files or modified; %s", name, remedy) + } + if expected != nil && !reflect.DeepEqual(c, *expected) { + return fmt.Errorf("shared component %s differs from this distribution's bundle.json; %s", name, remedy) + } + if err = checkComponentFiles(ctx, dir, c); errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + return fmt.Errorf("shared component %s is missing files, modified or unreadable; %s", name, remedy) + } else if err != nil { + return fmt.Errorf("install: cannot verify shared component %s: %w", name, err) + } + return nil +} + +// ensureNativeComponent makes the store hold name: a stored component is verified, a missing one is +// copied from the distribution directory or, with a Core address, downloaded. It is published +// once, complete and read-only, so an interruption leaves nothing partial in place. +func ensureNativeComponent(ctx context.Context, out io.Writer, o *nativeInstallOptions, name string, expected nativeComponent) error { + dest := filepath.Join(o.ComponentDirectory, name) if _, err := os.Lstat(dest); err == nil { - got, e := componentReceipt(dest) - if e != nil || !reflect.DeepEqual(got, expected) { - return fmt.Errorf("install: existing %s is incompatible; preserve it and use a separate installation directory", name) - } - if err := checkComponentFiles(ctx, dest, got); err != nil { - if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("install: existing %s is incomplete or modified; reinstall separately", name) - } - return fmt.Errorf("install: cannot verify existing %s: %w", name, err) + err = nativeInstallPhase(out, "Using installed "+name, func() error { return verifyNativeComponent(ctx, dest, name, &expected) }) + if err != nil { + return err } - return nil + // An interruption between the rename and the seal leaves the top directory writable. + return sealNativeComponent(dest) } else if !errors.Is(err, os.ErrNotExist) { return err } - parent := filepath.Dir(dest) - if err := os.MkdirAll(parent, 0700); err != nil { + source := filepath.Join(o.Bundle, "components", name) + _, missing := os.Lstat(source) + if missing != nil && o.OnboardURL == "" { + return fmt.Errorf("install: component %s is missing from %s; extract %s-%s-%s.tar.gz into it and retry", name, o.Bundle, runtime.GOOS, runtime.GOARCH, name) + } + stage, err := os.MkdirTemp(o.ComponentDirectory, ".install-"+name+"-") + if err != nil { return err } - tmp, err := os.MkdirTemp(parent, ".install-"+name+"-") + defer removeNativeTree(stage) + var archive string + if missing == nil { + err = nativeInstallPhase(out, "Installing "+name, func() error { return copyNativeComponent(ctx, source, stage, expected) }) + } else { + // A download prints its own progress, which a redrawn phase line would interleave with. + fmt.Fprintln(out, "Installing "+name+"...") + archive, err = downloadNativeComponent(ctx, out, o.OnboardURL, name, stage, expected) + } if err != nil { return err } - defer os.RemoveAll(tmp) - src, err := os.OpenRoot(filepath.Join(source, "components", name)) + raw, _ := json.Marshal(expected) + if err = os.WriteFile(filepath.Join(stage, ".oac-install.json"), raw, 0600); err != nil { + return err + } + if err = ctx.Err(); err != nil { + return err + } + if err = setNativeReadOnly(stage); err != nil { + return err + } + if err = os.Rename(stage, dest); err != nil { + return err + } + if archive != "" { + _ = os.Remove(archive) + } + return sealNativeComponent(dest) +} + +// copyNativeComponent copies the manifest's files from the distribution into stage, verifying each. +func copyNativeComponent(ctx context.Context, source, stage string, expected nativeComponent) error { + src, err := os.OpenRoot(source) if err != nil { return err } @@ -177,16 +230,13 @@ func installNativeComponent(ctx context.Context, source, root, name string, expe } required += uint64(info.Size()) } - if err = requireNativeSpace(parent, required); err != nil { + if err = requireNativeSpace(filepath.Dir(stage), required); err != nil { return err } for name, expectedFile := range expected.Files { if err := ctx.Err(); err != nil { return err } - if !validBundlePath(name) { - return errors.New("install: invalid component path") - } in, err := src.Open(filepath.FromSlash(name)) if err != nil { return errors.New("install: component file unavailable") @@ -196,7 +246,7 @@ func installNativeComponent(ctx context.Context, source, root, name string, expe in.Close() return errors.New("install: component must contain regular files") } - destination := filepath.Join(tmp, filepath.FromSlash(name)) + destination := filepath.Join(stage, filepath.FromSlash(name)) if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { in.Close() return err @@ -227,14 +277,7 @@ func installNativeComponent(ctx context.Context, source, root, name string, expe return errors.New("install: component checksum failed") } } - raw, _ := json.Marshal(expected) - if err = os.WriteFile(filepath.Join(tmp, ".oac-install.json"), raw, 0600); err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - return os.Rename(tmp, dest) + return nil } func selectedNativeHarnesses(value string) ([]string, error) { diff --git a/apps/daemon/internal/cli/native_discovery_test.go b/apps/daemon/internal/cli/native_discovery_test.go index e1dd6f225..1a71f932c 100644 --- a/apps/daemon/internal/cli/native_discovery_test.go +++ b/apps/daemon/internal/cli/native_discovery_test.go @@ -3,9 +3,13 @@ package cli import ( "context" "errors" + "fmt" "io" "reflect" + "strings" + "sync" "testing" + "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -17,31 +21,44 @@ func TestDiscoveryAndRegistration(t *testing.T) { runtimeRoot, stateRoot := t.TempDir(), t.TempDir() t.Setenv("OAC_RUNTIME_HOME", runtimeRoot) t.Setenv("OAC_RUNTIME_STATE_DIRECTORY", stateRoot) - called := []string{} + rc := &runContext{stdout: io.Discard, stderr: io.Discard} + expected := []string{"claude_sdk", "codex", "mcode"} + if selected != "" { + rc.installedKinds = map[string]bool{selected: true} + expected = []string{selected} + } + // Each probe waits for all selected probes to start, so a sequential + // discovery fails here instead of passing slowly. + var started sync.WaitGroup + started.Add(len(expected)) + all := make(chan struct{}) + go func() { started.Wait(); close(all) }() declarations := append([]agent.Declaration(nil), harnessDeclarations...) for i := range declarations { declarations[i].Discover = func(_ context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { if options.RuntimeRoot != runtimeRoot || options.StateRoot != stateRoot { - t.Fatal("discovery lost explicit directory context") + t.Error("discovery lost explicit directory context") + } + started.Done() + select { + case <-all: + case <-time.After(5 * time.Second): + t.Error("preflights did not run concurrently") } - called = append(called, info.Kind) + fmt.Fprintln(options.Stdout, info.Kind) info.Available = true info.Version = "test" return &agent.Runtime{Info: info} } } - rc := &runContext{stdout: io.Discard, stderr: io.Discard} - expected := []string{"codex", "mcode", "claude_sdk"} - if selected != "" { - rc.installedKinds = map[string]bool{selected: true} - expected = []string{selected} - } + var stdout strings.Builder + rc.stdout = &stdout discovery, err := discoverAgentCLIs(t.Context(), rc, "default", declarations) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(called, expected) { - t.Fatalf("probes %v, want %v", called, expected) + if got := strings.Fields(stdout.String()); !reflect.DeepEqual(got, expected) { + t.Fatalf("preflight output %v, want %v", got, expected) } registry := agent.NewRegistry() registerAgentKinds(registry, discovery) diff --git a/apps/daemon/internal/cli/native_download.go b/apps/daemon/internal/cli/native_download.go new file mode 100644 index 000000000..e79317190 --- /dev/null +++ b/apps/daemon/internal/cli/native_download.go @@ -0,0 +1,387 @@ +package cli + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" +) + +const nativeAttempts = 5 + +var ( + nativeStall = 60 * time.Second + nativeRetryDelay = time.Second + nativeDigest = regexp.MustCompile(`^[0-9a-f]{64}$`) + errNativeRedirect = errors.New("install: a download redirect must use HTTPS without credentials") + errNativeStall = errors.New("install: download stalled (no data for 60 seconds)") + errNativeNoArchive = errors.New("install: this Core has no qualified archive for this component and platform") +) + +// nativeClient follows redirects only to HTTPS, as the release host requires. +var nativeClient = &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) > 5 || req.URL.Scheme != "https" || req.URL.User != nil { + return errNativeRedirect + } + return nil +}} + +// nativeInstallerBase is where Core serves this release's installer archives. +func nativeInstallerBase(onboardURL string) string { + return strings.TrimSuffix(onboardURL, "installation") + "install/" + Version +} + +// nativeDownloadDirectory holds only transient files: the installer's bundle.* directories and the +// partial archive that a retry or a later run resumes. +func nativeDownloadDirectory() (string, error) { + home, err := paths.Root() + if err != nil { + return "", err + } + cache := filepath.Join(home, "native-download") + if err = runtimefs.EnsurePrivateDir(cache); err != nil { + return "", err + } + pruneNativeDownloads(cache) + dir := filepath.Join(cache, Version) + return dir, os.MkdirAll(dir, 0700) +} + +// downloadNativeComponent fetches the archive of one component, verifies it against Core's +// checksum and extracts it into stage. It returns the archive, which the caller deletes once the +// component is published. +func downloadNativeComponent(ctx context.Context, out io.Writer, onboardURL, name, stage string, expected nativeComponent) (string, error) { + dir, err := nativeDownloadDirectory() + if err != nil { + return "", err + } + file := runtime.GOOS + "-" + runtime.GOARCH + "-" + name + archive, err := fetchNativeArchive(ctx, out, nativeInstallerBase(onboardURL)+"/"+file, dir, file) + if err != nil { + return "", err + } + return archive, extractNativeComponent(ctx, out, archive, stage, name, expected) +} + +// pruneNativeDownloads keeps the current version and recent distribution directories of running +// installers. Failing to remove an old entry is harmless. +func pruneNativeDownloads(cache string) { + entries, _ := os.ReadDir(cache) + for _, entry := range entries { + info, err := entry.Info() + recent := err == nil && strings.HasPrefix(entry.Name(), "bundle.") && time.Since(info.ModTime()) < 24*time.Hour + if entry.Name() != Version && !recent { + _ = os.RemoveAll(filepath.Join(cache, entry.Name())) + } + } +} + +// fetchNativeArchive returns the verified archive, downloading or resuming it as needed. +func fetchNativeArchive(ctx context.Context, out io.Writer, url, dir, file string) (string, error) { + want, err := fetchNativeChecksum(ctx, out, url+".sha256") + if err != nil { + return "", err + } + part := filepath.Join(dir, file+".tar.gz.part") + for pass := 0; ; pass++ { + resumed := false + if info, err := os.Stat(part); err == nil && info.Size() > 0 { + resumed = true + fmt.Fprintf(out, "Resuming the earlier %s download...\n", file) + } + if err = retryNativeDownload(ctx, out, func() (bool, error) { return downloadNativeFile(ctx, out, url+".tar.gz", part, file) }); err != nil { + return "", err + } + got, err := nativeFileDigest(ctx, part) + if err != nil { + return "", err + } + if got == want { + return part, nil + } + _ = os.Remove(part) + // A resumed file may have been damaged earlier; one clean download decides. + if !resumed || pass > 0 { + return "", errors.New("install: archive checksum mismatch; the download was discarded, rerun the command to download it again") + } + } +} + +func fetchNativeChecksum(ctx context.Context, out io.Writer, url string) (string, error) { + var digest string + err := retryNativeDownload(ctx, out, func() (bool, error) { + ctx, cancel := context.WithTimeoutCause(ctx, nativeStall, errNativeStall) + defer cancel() + response, retry, err := getNative(ctx, url, 0) + if err != nil { + return retry, stalled(ctx, err) + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, 1025)) + digest = strings.TrimSpace(string(raw)) + if err != nil { + return true, stalled(ctx, err) + } + if !nativeDigest.MatchString(digest) { + return false, errors.New("install: Core returned an invalid archive checksum") + } + return false, nil + }) + return digest, err +} + +// getNative returns a 200 or 206 response, or whether its failure is worth another attempt. +func getNative(ctx context.Context, url string, offset int64) (*http.Response, bool, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false, errors.New("install: invalid Core installer address") + } + if offset > 0 { + request.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) + } + response, err := nativeClient.Do(request) + if err != nil { + return nil, !errors.Is(err, errNativeRedirect), err + } + code := response.StatusCode + switch { + case code == http.StatusOK || code == http.StatusPartialContent || code == http.StatusRequestedRangeNotSatisfiable: + return response, false, nil + case code == http.StatusNotFound: + err = errNativeNoArchive + default: + err = fmt.Errorf("install: download failed (HTTP %d); check Core and the download host", code) + } + response.Body.Close() + return nil, code == 408 || code == 429 || code >= 500, err +} + +func retryNativeDownload(ctx context.Context, out io.Writer, attempt func() (bool, error)) error { + for n := 1; ; n++ { + retry, err := attempt() + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if !retry || nativeDiskFull(err) { + return err + } + if n == nativeAttempts { + return fmt.Errorf("%w (downloaded data is kept; check the network to Core and GitHub, set HTTPS_PROXY if a proxy is required, and rerun the command)", err) + } + fmt.Fprintf(out, "Download interrupted; retrying (%d/%d)...\n", n, nativeAttempts-1) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(n) * nativeRetryDelay): + } + } +} + +// downloadNativeFile appends to dest from where an earlier attempt stopped and fails when no data +// arrives for nativeStall. There is no total time limit. +func downloadNativeFile(ctx context.Context, out io.Writer, url, dest, label string) (bool, error) { + var offset int64 + if info, err := os.Stat(dest); err == nil { + offset = info.Size() + } + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + stall := time.AfterFunc(nativeStall, func() { cancel(errNativeStall) }) + defer stall.Stop() + response, retry, err := getNative(ctx, url, offset) + if err != nil { + return retry, stalled(ctx, err) + } + defer response.Body.Close() + flags := os.O_WRONLY | os.O_CREATE | os.O_APPEND + switch response.StatusCode { + case http.StatusRequestedRangeNotSatisfiable: + return false, nil // The partial file already holds everything; the checksum decides. + case http.StatusOK: + flags, offset = os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0 + } + if response.ContentLength > 0 { + if err = requireNativeSpace(filepath.Dir(dest), uint64(response.ContentLength)); err != nil { + return false, err + } + } + file, err := os.OpenFile(dest, flags, 0600) + if err != nil { + return false, err + } + progress := &nativeProgress{out: out, label: label, done: offset} + if response.ContentLength > 0 { + progress.total = offset + response.ContentLength + } + _, err = io.Copy(file, &nativeBody{Reader: response.Body, stall: stall, progress: progress}) + if closeErr := file.Close(); err == nil { + err = closeErr + } + return err != nil, stalled(ctx, err) +} + +// stalled reports a stall instead of the cancellation it caused. +func stalled(ctx context.Context, err error) error { + if err != nil && errors.Is(context.Cause(ctx), errNativeStall) { + return errNativeStall + } + return err +} + +type nativeBody struct { + io.Reader + stall *time.Timer + progress *nativeProgress +} + +func (b *nativeBody) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + if n > 0 { + b.stall.Reset(nativeStall) + b.progress.add(int64(n)) + } + return n, err +} + +// nativeProgress prints one short line for each tenth of a download. +type nativeProgress struct { + out io.Writer + label string + done, total int64 + step int64 +} + +func (p *nativeProgress) add(n int64) { + p.done += n + if p.total == 0 { + return + } + if step := p.done * 10 / p.total; step > p.step { + p.step = step + fmt.Fprintf(p.out, "Downloading %s: %d%% (%d of %d MiB)\n", p.label, step*10, p.done>>20, p.total>>20) + } +} + +func nativeFileDigest(ctx context.Context, path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err = nativeCopy(ctx, hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// extractNativeComponent unpacks components// from a verified archive into stage. Only regular +// files and directories under that path are accepted, and only files that bundle.json lists; every +// file is then checked against its digest. +func extractNativeComponent(ctx context.Context, out io.Writer, archive, stage, name string, expected nativeComponent) error { + var size uint64 + if err := eachNativeEntry(ctx, archive, name, func(h *tar.Header, _ string, _ io.Reader) error { + size += uint64(h.Size) + return nil + }); err != nil { + return err + } + if err := requireNativeSpace(filepath.Dir(stage), size); err != nil { + return err + } + fmt.Fprintf(out, "Extracting %s...\n", name) + if err := eachNativeEntry(ctx, archive, name, func(h *tar.Header, rel string, r io.Reader) error { + target := filepath.Join(stage, filepath.FromSlash(rel)) + if h.Typeflag == tar.TypeDir { + return os.MkdirAll(target, 0700) + } + listed, ok := expected.Files[rel] + if !ok { + return errors.New("install: component archive holds a file that bundle.json does not list") + } + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + mode := os.FileMode(0600) + if listed.Executable { + mode = 0700 + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return err + } + _, err = nativeCopy(ctx, file, r) + if closeErr := file.Close(); err == nil { + err = closeErr + } + return err + }); err != nil { + return err + } + if err := checkComponentFiles(ctx, stage, expected); err != nil { + if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { + return errors.New("install: component archive does not match bundle.json; Core serves inconsistent installer files") + } + return err + } + return nil +} + +// eachNativeEntry visits the files and directories of a component archive; the root directory itself is skipped. +func eachNativeEntry(ctx context.Context, archive, name string, visit func(*tar.Header, string, io.Reader) error) error { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + zipped, err := gzip.NewReader(nativeCopyReader{ctx: ctx, Reader: file}) + if err != nil { + return errors.New("install: component archive is invalid; remove it and retry") + } + reader := tar.NewReader(zipped) + prefix := "components/" + name + "/" + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("install: component archive is invalid or incomplete: %w", err) + } + if header.Typeflag != tar.TypeDir && header.Typeflag != tar.TypeReg { + return errors.New("install: component archive may contain only files and directories") + } + path := header.Name + if header.Typeflag == tar.TypeDir { + path = strings.TrimSuffix(path, "/") + "/" + } + rel, ok := strings.CutPrefix(path, prefix) + rel = strings.TrimSuffix(rel, "/") + if !ok || (rel == "" && header.Typeflag != tar.TypeDir) || (rel != "" && !validBundlePath(rel)) { + return errors.New("install: component archive contains an unexpected path") + } + if rel == "" { + continue + } + if err = visit(header, rel, reader); err != nil { + return err + } + } +} diff --git a/apps/daemon/internal/cli/native_download_test.go b/apps/daemon/internal/cli/native_download_test.go new file mode 100644 index 000000000..86b9f7c91 --- /dev/null +++ b/apps/daemon/internal/cli/native_download_test.go @@ -0,0 +1,227 @@ +package cli + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// fakeCore serves the installation endpoints and one archive per component. +type fakeCore struct { + *httptest.Server + mu sync.Mutex + archives map[string][]byte + bodies map[string]int // archive downloads by component + breakOn string // component whose first download is cut off halfway + status int // answer for /installation + redirect string // when set, archives redirect here +} + +func newFakeCore(t *testing.T, workspace string) (*fakeCore, string) { + t.Helper() + environment := uuid.NewString() + core := &fakeCore{archives: map[string][]byte{}, bodies: map[string]int{}, status: 200} + core.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + core.mu.Lock() + defer core.mu.Unlock() + switch { + case strings.HasSuffix(r.URL.Path, "/installation/claim"): + w.WriteHeader(http.StatusNoContent) + case strings.HasSuffix(r.URL.Path, "/installation"): + if core.status != 200 { + w.WriteHeader(core.status) + return + } + _ = json.NewEncoder(w).Encode(v1.NativeInstallationContext{Version: Version, ProtocolVersion: proto.Version, EnvironmentID: environment, + RemoteURL: strings.Replace(core.URL, "http://", "ws://", 1) + "/api/v1/agent-daemon/ws", Workspace: workspace, Harness: nativeHarnesses["codex"].AgentKind}) + default: + name, ext, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/api/v1/agent-daemon/install/"+Version+"/"+runtime.GOOS+"-"+runtime.GOARCH+"-"), ".") + data, ok := core.archives[name] + switch { + case !ok: + w.WriteHeader(http.StatusNotFound) + case ext == "sha256": + fmt.Fprintf(w, "%x\n", sha256.Sum256(data)) + case core.redirect != "": + http.Redirect(w, r, core.redirect, http.StatusTemporaryRedirect) + case name == core.breakOn && r.Header.Get("Range") == "": + core.bodies[name]++ + w.Header().Set("Content-Length", fmt.Sprint(len(data))) + _, _ = w.Write(data[:len(data)/2]) + w.(http.Flusher).Flush() + panic(http.ErrAbortHandler) + default: + if r.Header.Get("Range") == "" { + core.bodies[name]++ + } + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) + } + } + })) + t.Cleanup(core.Close) + return core, environment +} + +// add publishes a component archive and returns its bundle.json entry. +func (c *fakeCore) add(t *testing.T, name string) nativeComponent { + t.Helper() + data := []byte("fixture " + name + strings.Repeat(" padding", 4096)) + sum := sha256.Sum256(data) + var archive bytes.Buffer + zipped := gzip.NewWriter(&archive) + writer := tar.NewWriter(zipped) + for _, header := range []*tar.Header{{Name: "components/" + name + "/", Typeflag: tar.TypeDir, Mode: 0755}, {Name: "components/" + name + "/program", Typeflag: tar.TypeReg, Mode: 0755, Size: int64(len(data))}} { + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + } + _, _ = writer.Write(data) + if writer.Close() != nil || zipped.Close() != nil { + t.Fatal("cannot build archive") + } + c.archives[name] = archive.Bytes() + return nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} +} + +func nativeDownloadInstall(t *testing.T, core *fakeCore, bundle string, extra ...string) error { + t.Helper() + t.Setenv(v1.NativeInstallGrantEnv, "grant") + out := new(bytes.Buffer) + rc := &runContext{stdin: strings.NewReader(""), stdout: out, stderr: out} + o, err := parseNativeInstall(rc, append([]string{"--non-interactive", "--onboard-url", core.URL + "/api/v1/agent-daemon/installation", "--bundle-dir", bundle, "--harness", "codex"}, extra...)) + if err != nil { + return err + } + return installNativeOptions(context.Background(), rc, &o) +} + +func writeBundle(t *testing.T, bundle string, components map[string]nativeComponent) { + t.Helper() + raw, _ := json.Marshal(nativeBundle{Schema: 1, DaemonVersion: Version, OS: runtime.GOOS, Arch: runtime.GOARCH, Components: components}) + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) + } +} + +func TestNativeInstallDownloadsOnlySelectedComponentsOnceAndResumes(t *testing.T) { + home := nativeTestHome(t) + oldDelay, oldProbe := nativeRetryDelay, probeNativeInstallation + nativeRetryDelay = time.Millisecond + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + t.Cleanup(func() { nativeRetryDelay, probeNativeInstallation = oldDelay, oldProbe }) + core, _ := newFakeCore(t, t.TempDir()) + components := map[string]nativeComponent{} + for _, name := range []string{"node", "codex", "claude"} { + components[name] = core.add(t, name) + } + core.breakOn = "codex" + cache := filepath.Join(home, "native-download") + if err := os.MkdirAll(filepath.Join(cache, "older-version"), 0700); err != nil { + t.Fatal(err) + } + // Two Sessions of one release on one machine download each component once, into one store. + var installations []string + for i := 0; i < 2; i++ { + bundle, installation := t.TempDir(), t.TempDir() + installations = append(installations, installation) + writeBundle(t, bundle, components) + if err := nativeDownloadInstall(t, core, bundle, "--install-dir", installation); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(bundle, "components")); err == nil { + t.Fatal("extracted into the distribution directory") + } + if _, err := os.Stat(filepath.Join(installation, "components")); err == nil { + t.Fatal("copied components into the installation") + } + } + if core.bodies["node"] != 1 || core.bodies["claude"] != 0 { + t.Fatalf("unexpected downloads: %v", core.bodies) + } + if core.bodies["codex"] != 1 { + t.Fatalf("codex was fetched %d times, want its interrupted download resumed", core.bodies["codex"]) + } + store := nativeTestStore(t) + for _, name := range []string{"node", "codex"} { + if err := verifyNativeComponent(context.Background(), filepath.Join(store, name), name, nil); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(store, "claude")); err == nil { + t.Fatal("downloaded a Harness the Sessions do not use") + } + if left, _ := filepath.Glob(filepath.Join(cache, Version, "*")); len(left) != 0 { + t.Fatalf("archives remain after extraction: %v", left) + } + if _, err := os.Stat(filepath.Join(cache, "older-version")); err == nil { + t.Fatal("downloads of another version were kept") + } +} + +func TestNativeInstallRejectsGrantBeforeDownloading(t *testing.T) { + nativeTestHome(t) + core, _ := newFakeCore(t, t.TempDir()) + core.status = http.StatusUnauthorized + if err := nativeDownloadInstall(t, core, t.TempDir()); err == nil || !strings.Contains(err.Error(), "authorization rejected") { + t.Fatalf("expected a rejected grant, got %v", err) + } + if len(core.bodies) != 0 { + t.Fatal("downloaded after a rejected grant") + } +} + +func TestNativeInstallRefusesRedirectToHTTP(t *testing.T) { + nativeTestHome(t) + core, _ := newFakeCore(t, t.TempDir()) + components := map[string]nativeComponent{"node": core.add(t, "node"), "codex": core.add(t, "codex")} + core.redirect = "http://127.0.0.1:1/archive.tar.gz" + bundle := t.TempDir() + writeBundle(t, bundle, components) + if err := nativeDownloadInstall(t, core, bundle, "--install-dir", t.TempDir()); err == nil || !strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("expected an HTTPS-only redirect error, got %v", err) + } +} + +func TestNativeInstallFromDistributionNamesMissingArchive(t *testing.T) { + rc, args, _, bundle := nativeInstallFixture(t) + if err := os.RemoveAll(bundleComponent(bundle, "codex")); err != nil { + t.Fatal(err) + } + err := runInstall(rc, args) + want := fmt.Sprintf("extract %s-%s-codex.tar.gz", runtime.GOOS, runtime.GOARCH) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("expected an error naming %s, got %v", want, err) + } +} + +func TestNativeDownloadFailsClearlyWhenCoreStopsAnswering(t *testing.T) { + oldStall, oldDelay := nativeStall, nativeRetryDelay + nativeStall, nativeRetryDelay = 50*time.Millisecond, time.Millisecond + t.Cleanup(func() { nativeStall, nativeRetryDelay = oldStall, oldDelay }) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release })) + t.Cleanup(func() { close(release); server.Close() }) + if _, err := fetchNativeChecksum(context.Background(), io.Discard, server.URL+"/linux-amd64-codex.sha256"); !errors.Is(err, errNativeStall) { + t.Fatal(err) + } +} diff --git a/apps/daemon/internal/cli/native_harness.go b/apps/daemon/internal/cli/native_harness.go index 69aa94b91..34bc1f300 100644 --- a/apps/daemon/internal/cli/native_harness.go +++ b/apps/daemon/internal/cli/native_harness.go @@ -23,19 +23,19 @@ func nativeExe(name string) string { } return name } -func nativeNode(root string) string { - base := nativeComponentRoot(root, "node") +func nativeNode(components string) string { + base := filepath.Join(components, "node") if runtime.GOOS != "windows" { base = filepath.Join(base, "bin") } return filepath.Join(base, nativeExe("node")) } -func nativeHarnessEnvironment(root string, selected []string) map[string]string { - node := nativeNode(root) +func nativeHarnessEnvironment(components string, selected []string) map[string]string { + node := nativeNode(components) values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} for _, name := range selected { - dir := nativeComponentRoot(root, name) + dir := filepath.Join(components, name) values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] for key, value := range nativeHarnesses[name].Environment(dir, node) { values[key] = value @@ -73,10 +73,10 @@ var nativeHarnesses = map[string]agent.Installation{ } var probeNativeInstallation = checkNativeInstallation -func checkNativeInstallation(ctx context.Context, root string, selected []string) error { - env := withNativeEnv(nativeHarnessEnvironment(root, selected)) - node := nativeNode(root) - version, err := installroot.Probe(ctx, node, []string{"--version"}, env, root) +func checkNativeInstallation(ctx context.Context, components string, selected []string) error { + env := withNativeEnv(nativeHarnessEnvironment(components, selected)) + node := nativeNode(components) + version, err := installroot.Probe(ctx, node, []string{"--version"}, env, components) if err != nil || version != "v"+nativePins["node"] { return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") } @@ -85,7 +85,7 @@ func checkNativeInstallation(ctx context.Context, root string, selected []string if !ok || !spec.Supported() { return fmt.Errorf("install: %s is unsupported on this platform", name) } - if err = spec.Check(ctx, nativeComponentRoot(root, name), node, env); err != nil { + if err = spec.Check(ctx, filepath.Join(components, name), node, env); err != nil { return fmt.Errorf("install: %s; no installed files were replaced", err) } } diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go index a59fc9d5c..dbec5a2f2 100644 --- a/apps/daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -3,12 +3,11 @@ package cli import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "flag" "fmt" + "io" "os" "path/filepath" "slices" @@ -27,6 +26,7 @@ type nativeInstallation struct { Workspace string `json:"workspace_directory"` Credential string `json:"credential_file"` CapabilityDirectory string `json:"capability_directory"` + ComponentDirectory string `json:"component_directory"` ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` Harnesses []string `json:"harnesses"` } @@ -56,6 +56,14 @@ func lockNativeInstallation(root string) (*os.Root, func(), error) { return held, func() { unlock(); held.Close() }, nil } +// validateNativeComponentDirectory accepts only a version directory of a components store. +func validateNativeComponentDirectory(dir string) error { + if runtimefs.ValidateLocalPath(dir) != nil || filepath.Base(dir) != Version || filepath.Base(filepath.Dir(dir)) != "components" { + return errors.New("invalid component directory") + } + return nil +} + func validateNativeInstallation(c nativeInstallation) error { if c.Version != Version { return errors.New("installation version is unsupported; preserve this installation and reinstall in a separate directory") @@ -71,6 +79,9 @@ func validateNativeInstallation(c nativeInstallation) error { return errors.New("install: --workspace, --credential-file and capability destination must be clean absolute paths") } } + if validateNativeComponentDirectory(c.ComponentDirectory) != nil { + return errors.New("installation component directory is invalid; reinstall in a separate directory") + } info, err := os.Stat(c.Workspace) if err != nil || !info.IsDir() { return errors.New("install: --workspace requires an existing directory") @@ -145,6 +156,35 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if err = cleanNativeTemporaryFiles(o.Directory); err != nil { return err } + // A rerun keeps the shared components its installation already uses. + var previous nativeInstallation + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("install: cannot read existing installation") + } + if len(previous.Harnesses) > 0 { + if validateNativeComponentDirectory(previous.ComponentDirectory) != nil { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + o.ComponentDirectory = previous.ComponentDirectory + } else if o.ComponentDirectory, err = nativeStoreDirectory(); err != nil { + return err + } + // An installation that did not publish its record must not pin the shared components. Removing + // the entry only lowers the count, so it needs no store lock; a later install or uninstall collects. + defer func() { + if _, err := os.Stat(filepath.Join(o.Directory, "daemon", "installation.json")); errors.Is(err, os.ErrNotExist) { + _ = os.Remove(nativeUserEntry(o.ComponentDirectory, o.Directory)) + } + }() + // Fetch before claiming the credential, so a failed download leaves the Session's credential unclaimed. + if err = ensureNativeComponents(ctx, rc.stdout, o, bundle, selected); err != nil { + return err + } if o.OnboardURL != "" { if runtimefs.ValidateLocalPath(o.Workspace) != nil { return errors.New("install: Session workspace is invalid on this platform") @@ -159,12 +199,7 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if err = validateNativeInstallation(o.nativeInstallation); err != nil { return err } - var previous nativeInstallation - raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) - if err == nil { - if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { - return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") - } + if len(previous.Harnesses) > 0 { wanted := o.nativeInstallation wanted.Harnesses = nil comparison := previous @@ -174,8 +209,6 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if !bytes.Equal(before, after) { return errors.New("install: existing connection settings differ; additive installation cannot replace them") } - } else if !errors.Is(err, os.ErrNotExist) { - return errors.New("install: cannot read existing installation") } all := append([]string{}, previous.Harnesses...) for _, name := range selected { @@ -184,22 +217,16 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO } } slices.Sort(all) - // Verify existing components before adding any new one; never repair or - // upgrade an installed dependency as a side effect of adding a Harness. - if len(previous.Harnesses) > 0 { - if err = nativeInstallPhase(rc.stdout, "Verifying installed components", func() error { return verifyNativeComponents(ctx, o.Directory, previous.Harnesses) }); err != nil { + // Harnesses installed earlier but not selected now were not touched above; they are verified, never repaired. + if untouched := slices.DeleteFunc(slices.Clone(previous.Harnesses), func(name string) bool { return slices.Contains(selected, name) }); len(untouched) > 0 { + if err = nativeInstallPhase(rc.stdout, "Verifying installed components", func() error { return verifyNativeComponents(ctx, o.ComponentDirectory, untouched) }); err != nil { return err } } if err = nativeInstallPhase(rc.stdout, "Installing Runtime", func() error { return installNativeBinary(ctx, o.Directory, len(previous.Harnesses) > 0) }); err != nil { return err } - for _, name := range append([]string{"node"}, selected...) { - if err = nativeInstallPhase(rc.stdout, "Installing "+name, func() error { return installNativeComponent(ctx, o.Bundle, o.Directory, name, bundle.Components[name]) }); err != nil { - return err - } - } - if err = nativeInstallPhase(rc.stdout, "Checking installed programs", func() error { return probeNativeInstallation(ctx, o.Directory, all) }); err != nil { + if err = nativeInstallPhase(rc.stdout, "Checking installed programs", func() error { return probeNativeInstallation(ctx, o.ComponentDirectory, all) }); err != nil { return err } if err = ctx.Err(); err != nil { @@ -218,20 +245,40 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO return nil } -func verifyNativeComponents(ctx context.Context, root string, selected []string) error { +// ensureNativeComponents registers the installation as a user of this version's shared components, +// collects what no installation uses, and installs the missing components. It holds the store lock. +func ensureNativeComponents(ctx context.Context, out io.Writer, o *nativeInstallOptions, bundle nativeBundle, selected []string) error { + unlock, err := lockNativeStore(ctx, out, filepath.Dir(o.ComponentDirectory)) + if err != nil { + return fmt.Errorf("install: %w", err) + } + defer unlock() + if err = requireNativeDirectory(o.ComponentDirectory); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("install: %w", err) + } + if err = addNativeUser(o.ComponentDirectory, o.Directory); err != nil { + return err + } + _ = collectNativeStore(filepath.Dir(o.ComponentDirectory)) + if err = cleanNativeStaging(o.ComponentDirectory); err != nil { + return err + } for _, name := range append([]string{"node"}, selected...) { + if err = ensureNativeComponent(ctx, out, o, name, bundle.Components[name]); err != nil { + return err + } + } + return nil +} + +// verifyNativeComponents checks node and the named Harnesses in the shared components directory. +func verifyNativeComponents(ctx context.Context, components string, harnesses []string) error { + for _, name := range append([]string{"node"}, harnesses...) { if _, ok := nativePins[name]; !ok { return errors.New("installation contains an unsupported Harness") } - c, err := componentReceipt(nativeComponentRoot(root, name)) - if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 { - return fmt.Errorf("installed %s is missing, modified or incompatible; reinstall separately (no automatic repair or upgrade)", name) - } - if err = checkComponentFiles(ctx, nativeComponentRoot(root, name), c); err != nil { - if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("installed %s is missing or modified; reinstall separately", name) - } - return fmt.Errorf("install: cannot verify installed %s: %w", name, err) + if err := verifyNativeComponent(ctx, filepath.Join(components, name), name, nil); err != nil { + return err } } return nil @@ -247,21 +294,11 @@ func installNativeBinary(ctx context.Context, root string, existing bool) error return err } dest := filepath.Join(dir, nativeExe("oac-daemon")) - digest := func(name string) (string, error) { - f, e := os.Open(name) - if e != nil { - return "", e - } - defer f.Close() - h := sha256.New() - _, e = nativeCopy(ctx, h, f) - return hex.EncodeToString(h.Sum(nil)), e - } - want, err := digest(exe) + want, err := nativeFileDigest(ctx, exe) if err != nil { return err } - if got, e := digest(dest); e == nil { + if got, e := nativeFileDigest(ctx, dest); e == nil { if got != want { return errors.New("install: existing daemon binary differs; in-place upgrades are unsupported") } @@ -337,10 +374,10 @@ func runStart(rc *runContext, args []string) error { err = errors.New("start: no installed Harnesses; rerun install with --harness") } if err == nil { - err = verifyNativeComponents(ctx, root, config.Harnesses) + err = verifyNativeComponents(ctx, config.ComponentDirectory, config.Harnesses) } if err == nil { - err = probeNativeInstallation(ctx, root, config.Harnesses) + err = probeNativeInstallation(ctx, config.ComponentDirectory, config.Harnesses) } unlock() if err != nil { @@ -349,7 +386,7 @@ func runStart(rc *runContext, args []string) error { previousKinds := rc.installedKinds rc.installedKinds = nativeInstallationKinds(config.Harnesses) defer func() { rc.installedKinds = previousKinds }() - values := nativeHarnessEnvironment(root, config.Harnesses) + values := nativeHarnessEnvironment(config.ComponentDirectory, config.Harnesses) values["OAC_RUNTIME_WORKSPACE"] = config.Workspace values["OAC_RUNTIME_CAPABILITY_DIRECTORY"] = config.CapabilityDirectory values["OAC_RUNTIME_TOOL_ENV_FILE"] = config.ToolEnvironmentFile diff --git a/apps/daemon/internal/cli/native_install_interrupt_unix_test.go b/apps/daemon/internal/cli/native_install_interrupt_unix_test.go index d8a7e84da..049c12eee 100644 --- a/apps/daemon/internal/cli/native_install_interrupt_unix_test.go +++ b/apps/daemon/internal/cli/native_install_interrupt_unix_test.go @@ -44,7 +44,7 @@ func TestNativeInstallationInterruptReapsProbe(t *testing.T) { script := []byte("#!/bin/sh\nsleep 300 &\nprintf '%s %s' \"$$\" \"$!\" > \"$OAC_TEST_PROBE_PIDS\"\nwait\n") sum := sha256.Sum256(script) b.Components["node"] = nativeComponent{Version: nativePins["node"], Files: map[string]nativeFile{"bin/node": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} - dir := filepath.Join(nativeComponentRoot(bundle, "node"), "bin") + dir := filepath.Join(bundleComponent(bundle, "node"), "bin") if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/native_install_io.go b/apps/daemon/internal/cli/native_install_io.go index 999e71a4b..788d284c7 100644 --- a/apps/daemon/internal/cli/native_install_io.go +++ b/apps/daemon/internal/cli/native_install_io.go @@ -13,11 +13,11 @@ import ( ) // nativeInstallationEntries are the top-level entries an installation directory -// owns: the installer writes bin, daemon, components and the default +// owns: the installer writes bin, daemon and the default // capabilities, and Windows keeps the lock file of daemon beside it; the daemon // running from it adds runtime, initialization and packages. Install refuses a directory with anything else, and uninstall // deletes nothing else. -var nativeInstallationEntries = []string{"bin", "daemon", "components", "capabilities", ".daemon.runtime.lock", "runtime", "initialization", "packages"} +var nativeInstallationEntries = []string{"bin", "daemon", "capabilities", ".daemon.runtime.lock", "runtime", "initialization", "packages"} // foreignEntries names what dir holds that no installation owns. func foreignEntries(dir string) ([]string, error) { @@ -39,9 +39,8 @@ func foreignEntries(dir string) ([]string, error) { // These exact temporary names are reserved by the installer, never workspace data. var nativeTemporaryNames = map[string]*regexp.Regexp{ - "components": regexp.MustCompile(`^\.install-(node|codex|claude|minimax)-[0-9]+$`), - "bin": regexp.MustCompile(`^\.oac-daemon-[0-9]+$`), - "daemon": regexp.MustCompile(`^\.(installation\.json|executor-credential\.json)-[0-9a-f]{24}\.tmp$`), + "bin": regexp.MustCompile(`^\.oac-daemon-[0-9]+$`), + "daemon": regexp.MustCompile(`^\.(installation\.json|executor-credential\.json)-[0-9a-f]{24}\.tmp$`), } // The caller holds the installation lock, including while recovering a failed copy. diff --git a/apps/daemon/internal/cli/native_install_recovery_test.go b/apps/daemon/internal/cli/native_install_recovery_test.go index 12dabf52f..32c3c8962 100644 --- a/apps/daemon/internal/cli/native_install_recovery_test.go +++ b/apps/daemon/internal/cli/native_install_recovery_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "io" "os" "path/filepath" "strings" @@ -12,7 +13,7 @@ import ( func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { rc, args, root, _ := nativeInstallFixture(t) - names := []string{"components/.install-codex-12345/partial", "bin/.oac-daemon-9876", "daemon/.installation.json-0123456789abcdef01234567.tmp"} + names := []string{"bin/.oac-daemon-9876", "daemon/.installation.json-0123456789abcdef01234567.tmp"} for _, name := range names { path := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { @@ -22,7 +23,7 @@ func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { t.Fatal(err) } } - keep := filepath.Join(root, "components", ".install-codex-user-notes") + keep := filepath.Join(root, "bin", ".oac-daemon-user-notes") if err := os.WriteFile(keep, []byte("keep"), 0600); err != nil { t.Fatal(err) } @@ -85,11 +86,11 @@ func TestNativeStagingCleanupDoesNotFollowLinks(t *testing.T) { if err := os.WriteFile(keep, []byte("keep"), 0600); err != nil { t.Fatal(err) } - parent := filepath.Join(root, "components") + parent := filepath.Join(root, "bin") if err := os.Mkdir(parent, 0700); err != nil { t.Fatal(err) } - if err := os.Symlink(outside, filepath.Join(parent, ".install-codex-123")); err != nil { + if err := os.Symlink(outside, filepath.Join(parent, ".oac-daemon-123")); err != nil { t.Skip("symlink permission unavailable") } if err := cleanNativeTemporaryFiles(root); err != nil { @@ -105,6 +106,7 @@ func TestNativeVerificationCancellationPreservesInstallation(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } + store := nativeTestStore(t) config := filepath.Join(root, "daemon", "installation.json") before, err := os.ReadFile(config) if err != nil { @@ -112,21 +114,21 @@ func TestNativeVerificationCancellationPreservesInstallation(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - if err = verifyNativeComponents(ctx, root, []string{"codex"}); !errors.Is(err, context.Canceled) { + if err = verifyNativeComponents(ctx, store, []string{"codex"}); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled verification reported damage: %v", err) } b, err := readNativeBundle(bundle, []string{"codex"}) if err != nil { t.Fatal(err) } - if err = installNativeComponent(ctx, bundle, root, "codex", b.Components["codex"]); !errors.Is(err, context.Canceled) { + if err = ensureNativeComponent(ctx, io.Discard, &nativeInstallOptions{nativeInstallation: nativeInstallation{ComponentDirectory: store}, Bundle: bundle}, "codex", b.Components["codex"]); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled reuse reported damage: %v", err) } after, err := os.ReadFile(config) if err != nil || !bytes.Equal(before, after) { t.Fatal("cancellation changed installation") } - if err = verifyNativeComponents(context.Background(), root, []string{"codex"}); err != nil { + if err = verifyNativeComponents(context.Background(), store, []string{"codex"}); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index d423c8c4f..bae3f95b7 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" @@ -17,12 +18,64 @@ import ( "github.com/google/uuid" ) +// nativeTestHome points the Runtime home at a fresh directory. The shared components are read-only, +// so cleanup makes them writable first. +func nativeTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + t.Cleanup(func() { _ = removeNativeTree(home) }) + return home +} + +func bundleComponent(bundle, name string) string { + return filepath.Join(filepath.Join(bundle, "components"), name) +} + +func nativeTestStore(t *testing.T) string { + t.Helper() + store, err := nativeStoreDirectory() + if err != nil { + t.Fatal(err) + } + return store +} + +// otherNativeInstallation returns arguments for a second Session on the same machine and distribution. +func otherNativeInstallation(t *testing.T, args []string) []string { + t.Helper() + other := append([]string{}, args...) + environment := uuid.NewString() + key := filepath.Join(t.TempDir(), "credential.json") + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) + if err := os.WriteFile(key, body, 0600); err != nil { + t.Fatal(err) + } + for flag, value := range map[string]string{"--environment-id": environment, "--credential-file": key, "--workspace": t.TempDir(), "--install-dir": t.TempDir()} { + other[slices.Index(other, flag)+1] = value + } + return other +} + +// uninstallNative runs the installed executable's uninstall for one installation directory. +func uninstallNative(t *testing.T, root string) string { + t.Helper() + old := os.Getenv("OAC_RUNTIME_HOME") + t.Setenv("OAC_RUNTIME_HOME", root) + defer os.Setenv("OAC_RUNTIME_HOME", old) + output := new(bytes.Buffer) + if err := runUninstall(&runContext{stdin: strings.NewReader("y\n"), stdout: output, stderr: output}, nil); err != nil { + t.Fatal(err) + } + return output.String() +} + func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) { t.Helper() root := t.TempDir() bundle := t.TempDir() workspace := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) + nativeTestHome(t) key := filepath.Join(t.TempDir(), "credential.json") environment := uuid.NewString() body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) @@ -34,7 +87,7 @@ func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) data := []byte("fixture " + name) sum := sha256.Sum256(data) b.Components[name] = nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} - dir := nativeComponentRoot(bundle, name) + dir := bundleComponent(bundle, name) if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } @@ -117,13 +170,17 @@ func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { t.Fatalf("could not reuse complete unpublished component: %v", err) } - // Source corruption cannot affect an existing component, and cannot be used - // to repair a modified installed one silently. - _ = os.WriteFile(filepath.Join(nativeComponentRoot(root, "codex"), "program"), []byte("changed"), 0700) - if err := runInstall(rc, args); err == nil { - t.Fatal("silently repaired modified executable") + // A modified shared component fails with the directory to delete and is never repaired silently. + component := filepath.Join(nativeTestStore(t), "codex") + program := filepath.Join(component, "program") + if err := os.Chmod(program, 0700); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(program, []byte("changed"), 0700) + if err := runInstall(rc, args); err == nil || !strings.Contains(err.Error(), component) || !strings.Contains(err.Error(), nativeDeleteCommand(component)) { + t.Fatalf("modified component: %v", err) } - if got, _ := os.ReadFile(filepath.Join(nativeComponentRoot(root, "codex"), "program")); string(got) != "changed" { + if got, _ := os.ReadFile(program); string(got) != "changed" { t.Fatal("overwrote installed data") } _ = bundle @@ -201,7 +258,7 @@ func TestNativeInstallationLockAndManifestContainment(t *testing.T) { if err = runInstall(rc, args); err == nil { t.Fatal("accepted manifest traversal") } - if _, err = os.Stat(filepath.Join(root, "components")); !errors.Is(err, os.ErrNotExist) { + if _, err = os.Stat(filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "components")); !errors.Is(err, os.ErrNotExist) { t.Fatal("manifest validation happened after mutation") } } @@ -248,10 +305,12 @@ func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } + store := nativeTestStore(t) + t.Setenv("OAC_RUNTIME_HOME", root) // The installed executable acts on its own installation. for _, mode := range []string{"installed", "missing-component"} { t.Run(mode, func(t *testing.T) { if mode == "missing-component" { - if err := os.Remove(filepath.Join(nativeComponentRoot(root, "codex"), "program")); err != nil { + if err := removeNativeTree(filepath.Join(store, "codex")); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/cli/native_start_interrupt_unix_test.go b/apps/daemon/internal/cli/native_start_interrupt_unix_test.go index 316498fda..5f639f826 100644 --- a/apps/daemon/internal/cli/native_start_interrupt_unix_test.go +++ b/apps/daemon/internal/cli/native_start_interrupt_unix_test.go @@ -57,7 +57,7 @@ func TestNativeStartInterruptCancelsEnrollment(t *testing.T) { t.Fatal(err) } cmd := exec.Command(os.Args[0], "-test.run=^TestNativeStartInterruptHelper$") - cmd.Env = append(os.Environ(), "OAC_TEST_START_INTERRUPT=1") + cmd.Env = append(os.Environ(), "OAC_TEST_START_INTERRUPT=1", "OAC_RUNTIME_HOME="+root) output := new(bytes.Buffer) cmd.Stdout, cmd.Stderr = output, output if err := cmd.Start(); err != nil { diff --git a/apps/daemon/internal/cli/native_store.go b/apps/daemon/internal/cli/native_store.go new file mode 100644 index 000000000..ccd03d257 --- /dev/null +++ b/apps/daemon/internal/cli/native_store.go @@ -0,0 +1,281 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" +) + +// The Runtime home keeps one installed copy of each component per daemon version, in +// components//. Every installation of that version on this machine uses it. +// The lock on components/ covers user entries, collection and installing missing components. +var ( + nativeStoreWait = 10 * time.Minute + nativeStoreRetry = time.Second +) + +func nativeStoreDirectory() (string, error) { + home, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(home, "components", Version), nil +} + +// requireNativeDirectory accepts a real directory owned by the current user, never a link. +func requireNativeDirectory(dir string) error { + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() || !ownedByCurrentUser(info) { + return fmt.Errorf("%s must be a directory owned by the current user, not a link", dir) + } + return nil +} + +// lockNativeStore waits for another process holding the store; any other lock failure is final. +func lockNativeStore(ctx context.Context, out io.Writer, root string) (func(), error) { + if _, err := os.Lstat(root); err == nil { + if err = requireNativeDirectory(root); err != nil { + return nil, err + } + } + if err := runtimefs.EnsurePrivateDir(root); err != nil { + return nil, err + } + held, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + deadline := time.Now().Add(nativeStoreWait) + for waiting := false; ; waiting = true { + unlock, err := runtimefs.LockDirectory(held) + if err == nil { + return func() { unlock(); held.Close() }, nil + } + if !errors.Is(err, runtimefs.ErrLocked) { + held.Close() + return nil, err + } + if time.Now().After(deadline) { + held.Close() + return nil, errors.New("another installation on this machine has held the shared components for 10 minutes; wait for it to finish or stop it, then rerun the command") + } + if !waiting { + fmt.Fprintln(out, "Waiting for another installation on this machine...") + } + select { + case <-ctx.Done(): + held.Close() + return nil, ctx.Err() + case <-time.After(nativeStoreRetry): + } + } +} + +// resolvedNativeDirectory is the spelling that user entries record and hash. +func resolvedNativeDirectory(dir string) string { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return resolved + } + return dir +} + +// nativeUserEntry names the file that records an installation as a user of a version directory. +func nativeUserEntry(version, installation string) string { + sum := sha256.Sum256([]byte(resolvedNativeDirectory(installation))) + return filepath.Join(version, "users", hex.EncodeToString(sum[:])) +} + +// addNativeUser records the installation; an unchanged entry is left alone, a new one is written atomically. +func addNativeUser(version, installation string) error { + entry := nativeUserEntry(version, installation) + want := []byte(resolvedNativeDirectory(installation)) + if got, err := os.ReadFile(entry); err == nil && string(got) == string(want) { + return nil + } + dir := filepath.Dir(entry) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + held, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer held.Close() + return runtimefs.WritePrivateAtomic(held, filepath.Base(entry), want) +} + +// countNativeUsers drops the entries of installations whose directory is gone and counts the rest. +// Whatever it cannot read or parse counts as a user. managed is false for a directory without a +// real users directory, which this store did not create. +func countNativeUsers(version string) (users int, managed bool) { + dir := filepath.Join(version, "users") + if info, err := os.Lstat(dir); err != nil || !info.IsDir() { + return 0, false + } + entries, err := os.ReadDir(dir) + if err != nil { + return 1, true + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".") { + continue // an interrupted atomic write + } + file := filepath.Join(dir, entry.Name()) + // An entry is dropped only when its installation directory is gone. + if raw, err := os.ReadFile(file); err == nil && filepath.IsAbs(string(raw)) { + if _, err = os.Stat(string(raw)); errors.Is(err, os.ErrNotExist) && os.Remove(file) == nil { + continue + } + } + users++ + } + return users, true +} + +// collectNativeStore removes every version directory that no installation uses. A directory is +// renamed away before it is deleted, so a version is either whole or gone; a rename that fails, +// as on Windows while a file is in use, keeps the version. Leftovers of an interrupted deletion are swept. +func collectNativeStore(root string) error { + var failure error + entries, _ := os.ReadDir(root) + for _, entry := range entries { + dir := filepath.Join(root, entry.Name()) + if strings.HasPrefix(entry.Name(), ".remove-") { + _ = removeNativeTree(dir) + continue + } + if !entry.IsDir() { + continue // never follow a link out of the store + } + if users, managed := countNativeUsers(dir); managed && users == 0 { + if err := removeNativeVersion(dir); err != nil && failure == nil { + failure = err + } + } + } + return failure +} + +func removeNativeVersion(dir string) error { + gone := filepath.Join(filepath.Dir(dir), fmt.Sprintf(".remove-%d-%d", os.Getpid(), time.Now().UnixNano())) + if err := os.Rename(dir, gone); err != nil { + return fmt.Errorf("cannot remove %s: %w", dir, err) + } + return removeNativeTree(gone) +} + +// releaseNativeStore drops one installation's entry and collects the store. It returns how many +// other installations still use the version. +func releaseNativeStore(ctx context.Context, out io.Writer, version, entry string) (int, error) { + root := filepath.Dir(version) + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return 0, nil + } + unlock, err := lockNativeStore(ctx, out, root) + if err != nil { + return 0, err + } + defer unlock() + if err = os.Remove(entry); err != nil && !errors.Is(err, os.ErrNotExist) { + return 0, err + } + if err = collectNativeStore(root); err != nil { + return 0, err + } + if _, err = os.Lstat(version); errors.Is(err, os.ErrNotExist) { + return 0, nil + } + users, _ := countNativeUsers(version) + if users == 0 { + return 0, fmt.Errorf("%s was not removed", version) + } + return users, nil +} + +// setNativeReadOnly makes the contents of a component unchangeable: files 0400 or, when +// executable, 0500, directories 0500. The top directory stays as it is, because macOS refuses to +// rename a directory that is not writable; sealNativeComponent closes it after the rename. +func setNativeReadOnly(path string) error { + var directories []string + err := filepath.WalkDir(path, func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if name != path { + directories = append(directories, name) + } + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := os.FileMode(0400) + if info.Mode().Perm()&0100 != 0 { + mode = 0500 + } + return os.Chmod(name, mode) + }) + for i := len(directories) - 1; err == nil && i >= 0; i-- { + err = os.Chmod(directories[i], 0500) + } + return err +} + +func sealNativeComponent(dir string) error { return os.Chmod(dir, 0500) } + +// removeNativeTree deletes a tree that setNativeReadOnly protected. Go's RemoveAll +// cannot delete read-only entries on Windows and cannot enter read-only directories elsewhere. +func removeNativeTree(path string) error { + _ = filepath.WalkDir(path, func(name string, entry fs.DirEntry, err error) error { + if err != nil || entry.Type()&fs.ModeSymlink != 0 { + return nil + } + if entry.IsDir() { + return os.Chmod(name, 0700) + } + return os.Chmod(name, 0600) + }) + return os.RemoveAll(path) +} + +// nativeDeleteCommand is the command that deletes a read-only component directory by hand. +func nativeDeleteCommand(dir string) string { + if runtime.GOOS == "windows" { + return "Remove-Item -Recurse -Force '" + strings.ReplaceAll(dir, "'", "''") + "'" + } + quoted := "'" + strings.ReplaceAll(dir, "'", `'\''`) + "'" + return "chmod -R u+w " + quoted + " && rm -rf " + quoted +} + +// cleanNativeStaging removes the unpublished copies an interrupted installation left. The caller holds the store lock. +func cleanNativeStaging(version string) error { + entries, err := os.ReadDir(version) + if err != nil { + return err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".install-") { + if err = removeNativeTree(filepath.Join(version, entry.Name())); err != nil { + return fmt.Errorf("install: cannot remove interrupted staging: %w", err) + } + } + } + return nil +} diff --git a/apps/daemon/internal/cli/native_store_test.go b/apps/daemon/internal/cli/native_store_test.go new file mode 100644 index 000000000..a6debfd3d --- /dev/null +++ b/apps/daemon/internal/cli/native_store_test.go @@ -0,0 +1,195 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" +) + +func TestNativeInstallationsShareOneReadOnlyCopy(t *testing.T) { + rc, args, first, bundle := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + store := nativeTestStore(t) + // The second installation needs no distribution: it finds every component in the store. + if err := os.RemoveAll(filepath.Join(bundle, "components")); err != nil { + t.Fatal(err) + } + args = otherNativeInstallation(t, args) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + second := args[slices.Index(args, "--install-dir")+1] + for _, dir := range []string{first, second} { + if _, err := os.Stat(filepath.Join(dir, "components")); err == nil { + t.Fatal("installation holds its own component copy") + } + var installed nativeInstallation + if err := readNativeJSON(filepath.Join(dir, "daemon", "installation.json"), &installed); err != nil || installed.ComponentDirectory != store { + t.Fatalf("installation does not reference the store: %v %q", err, installed.ComponentDirectory) + } + } + if info, err := os.Stat(filepath.Join(store, "codex", ".oac-install.json")); err != nil || info.Mode().Perm()&0200 != 0 { + t.Fatalf("component file is writable: %v", err) + } + if users, _ := countNativeUsers(store); users != 2 { + t.Fatalf("store has %d users, want 2", users) + } + if output := uninstallNative(t, first); !strings.Contains(output, "Shared components kept: 1 other") { + t.Fatalf("first uninstall:\n%s", output) + } + if _, err := os.Stat(filepath.Join(store, "codex")); err != nil { + t.Fatal("uninstalling one installation removed components another uses") + } + if output := uninstallNative(t, second); !strings.Contains(output, "Shared components removed") { + t.Fatalf("last uninstall:\n%s", output) + } + if _, err := os.Stat(store); !os.IsNotExist(err) { + t.Fatal("the last uninstall left the version directory") + } +} + +func TestNativeStoreDropsGoneInstallationsAndUnusedVersions(t *testing.T) { + rc, args, first, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + store := nativeTestStore(t) + // A version whose only installation is gone, with read-only content like a published component. + old := filepath.Join(filepath.Dir(store), "old-version") + gone := filepath.Join(t.TempDir(), "deleted") + if err := addNativeUser(old, gone); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(old, "node"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "node", "program"), nil, 0400); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(old, "node"), 0500); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, otherNativeInstallation(t, args)); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatal("unused version directory remains") + } + if users, _ := countNativeUsers(store); users != 1 { + t.Fatalf("store has %d users, want only the new installation", users) + } +} + +// safeBuffer lets the test read the output while the installation still writes it. +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestNativeStoreWaitsForAnotherInstallation(t *testing.T) { + rc, args, _, _ := nativeInstallFixture(t) + output := &safeBuffer{} + rc.stdout = output + oldRetry, oldWait := nativeStoreRetry, nativeStoreWait + nativeStoreRetry = 5 * time.Millisecond + t.Cleanup(func() { nativeStoreRetry, nativeStoreWait = oldRetry, oldWait }) + root := filepath.Dir(nativeTestStore(t)) + unlock, err := lockNativeStore(context.Background(), io.Discard, root) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- runInstall(rc, args) }() + for deadline := time.Now().Add(10 * time.Second); !strings.Contains(output.String(), "Waiting for another installation"); { + select { + case err = <-done: + t.Fatalf("installation did not wait: %v", err) + case <-time.After(5 * time.Millisecond): + } + if time.Now().After(deadline) { + t.Fatal("the wait was not announced") + } + } + unlock() + if err = <-done; err != nil { + t.Fatal(err) + } + unlock, err = lockNativeStore(context.Background(), io.Discard, root) + if err != nil { + t.Fatal(err) + } + defer unlock() + nativeStoreWait = 20 * time.Millisecond + if _, err = lockNativeStore(context.Background(), io.Discard, root); err == nil || !strings.Contains(err.Error(), "held the shared components") { + t.Fatalf("expected a timeout, got %v", err) + } +} + +func TestNativeStoreKeepsWhatItDidNotCreateOrCannotParse(t *testing.T) { + rc, args, _, _ := nativeInstallFixture(t) + store := nativeTestStore(t) + // Another program's directory in components/ is not a version directory. + unrelated := filepath.Join(filepath.Dir(store), "unrelated") + if err := os.MkdirAll(unrelated, 0700); err != nil { + t.Fatal(err) + } + // A failed first installation leaves no entry; the version is collected by a later install. + probeNativeInstallation = func(context.Context, string, []string) error { return errors.New("synthetic unavailable") } + if err := runInstall(rc, args); err == nil { + t.Fatal("published a failed installation") + } + if users, _ := countNativeUsers(store); users != 0 { + t.Fatal("a failed first installation pinned the shared components") + } + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + // An entry that cannot be read as a path counts as a user; a writable top directory is sealed again. + if err := os.WriteFile(filepath.Join(store, "users", "damaged"), nil, 0600); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(filepath.Join(store, "codex"), 0700); err != nil { + t.Fatal(err) + } + } + if err := runInstall(rc, otherNativeInstallation(t, args)); err != nil { + t.Fatal(err) + } + if users, _ := countNativeUsers(store); users != 3 { + t.Fatalf("store has %d users, want 3", users) + } + if info, err := os.Stat(filepath.Join(store, "codex")); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0500) { + t.Fatalf("component directory not sealed: %v", err) + } + if _, err := os.Stat(unrelated); err != nil { + t.Fatal("collection removed a directory it did not create") + } +} diff --git a/apps/daemon/internal/cli/native_store_unix.go b/apps/daemon/internal/cli/native_store_unix.go new file mode 100644 index 000000000..13b1d54f0 --- /dev/null +++ b/apps/daemon/internal/cli/native_store_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package cli + +import ( + "os" + "syscall" +) + +func ownedByCurrentUser(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && int(stat.Uid) == os.Getuid() +} diff --git a/apps/daemon/internal/cli/native_store_windows.go b/apps/daemon/internal/cli/native_store_windows.go new file mode 100644 index 000000000..5cc6b43f0 --- /dev/null +++ b/apps/daemon/internal/cli/native_store_windows.go @@ -0,0 +1,6 @@ +package cli + +import "os" + +// Windows directory ACLs, not owners, decide access; the account's profile already scopes the store. +func ownedByCurrentUser(os.FileInfo) bool { return true } diff --git a/apps/daemon/internal/cli/native_uninstall.go b/apps/daemon/internal/cli/native_uninstall.go index 5ee504578..4e875d730 100644 --- a/apps/daemon/internal/cli/native_uninstall.go +++ b/apps/daemon/internal/cli/native_uninstall.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "context" "errors" "fmt" "os" @@ -39,6 +40,11 @@ func runUninstall(rc *runContext, args []string) error { if config.Workspace != "" && within(root, config.Workspace) { return fmt.Errorf("uninstall: the workspace %s is inside the installation directory; move it out first", config.Workspace) } + // The entry is named while the installation directory still exists. + var entry string + if validateNativeComponentDirectory(config.ComponentDirectory) == nil { + entry = nativeUserEntry(config.ComponentDirectory, root) + } var owned []string for _, name := range nativeInstallationEntries { if _, err = os.Lstat(filepath.Join(root, name)); err == nil { @@ -53,7 +59,10 @@ func runUninstall(rc *runContext, args []string) error { for _, target := range owned { fmt.Fprintln(rc.stdout, " "+target) } - fmt.Fprintln(rc.stdout, "These hold the executor credential, native history and Harnesses. The workspace stays, and so does anything else in "+root+" and any credential or capability directory outside the installation.") + if entry != "" { + fmt.Fprintln(rc.stdout, "The shared components of this version are deleted too when no other installation uses them: "+config.ComponentDirectory) + } + fmt.Fprintln(rc.stdout, "These hold the executor credential and native history. The workspace stays, and so does anything else in "+root+" and any credential or capability directory outside the installation.") fmt.Fprint(rc.stdout, "Continue? [y/N] ") answer, _ := bufio.NewReader(rc.stdin).ReadString('\n') if !strings.EqualFold(strings.TrimSpace(answer), "y") { @@ -87,6 +96,7 @@ func runUninstall(rc *runContext, args []string) error { bin := filepath.Join(root, "bin") order = append(order, bin, record) var kept bool + var releaseErr error for _, target := range order { held, e := removeTree(target, running) if e != nil { @@ -103,6 +113,17 @@ func runUninstall(rc *runContext, args []string) error { _ = os.Remove(root) } fmt.Fprintln(rc.stdout, "oac-daemon: uninstalled") + if entry != "" { + users, err := releaseNativeStore(context.Background(), rc.stdout, config.ComponentDirectory, entry) + if err != nil { + releaseErr = fmt.Errorf("uninstall: the installation was removed but its shared components could not be released (%w); the next install or uninstall on this machine collects them", err) + fmt.Fprintln(rc.stdout, "Warning: "+releaseErr.Error()) + } else if users == 0 { + fmt.Fprintln(rc.stdout, "Shared components removed: "+config.ComponentDirectory) + } else { + fmt.Fprintf(rc.stdout, "Shared components kept: %d other installation(s) still use %s\n", users, config.ComponentDirectory) + } + } if config.Workspace != "" { fmt.Fprintln(rc.stdout, "Workspace kept: "+config.Workspace) } @@ -115,7 +136,7 @@ func runUninstall(rc *runContext, args []string) error { } } fmt.Fprintf(rc.stdout, "Core still accepts this machine's credential. In Web, open the Session (Environment %s) and choose Revoke in Executor credentials, or Delete the Session. This Session's install command cannot be used again; to reconnect it, choose Rotate and install from an extracted distribution, or create a new Session.\n", config.Environment) - return nil + return releaseErr } // removeTree deletes path. keep is the running executable, which Windows diff --git a/apps/daemon/internal/cli/native_uninstall_test.go b/apps/daemon/internal/cli/native_uninstall_test.go index c5c789e0b..604d37e72 100644 --- a/apps/daemon/internal/cli/native_uninstall_test.go +++ b/apps/daemon/internal/cli/native_uninstall_test.go @@ -50,6 +50,7 @@ func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { if err := os.WriteFile(foreignFile, []byte("keep"), 0600); err != nil { t.Fatal(err) } + t.Setenv("OAC_RUNTIME_HOME", root) output := new(bytes.Buffer) rc = &runContext{stdin: strings.NewReader("n\n"), stdout: output, stderr: output} if err := runUninstall(rc, nil); err == nil || !strings.Contains(output.String(), filepath.Join(root, "bin")) { @@ -63,7 +64,7 @@ func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { if err := runUninstall(rc, nil); err != nil { t.Fatal(err) } - for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session", "Kept: " + foreignFile, "Kept: " + foreignDir, "Kept: " + credential, "Rotate"} { + for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session", "Kept: " + foreignFile, "Kept: " + foreignDir, "Kept: " + credential, "Rotate", "Shared components removed"} { if !strings.Contains(output.String(), text) { t.Fatalf("missing %q in\n%s", text, output) } @@ -90,10 +91,7 @@ func TestUninstallRemovesEmptiedInstallationDirectory(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } - rc = &runContext{stdin: strings.NewReader("y\n"), stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)} - if err := runUninstall(rc, nil); err != nil { - t.Fatal(err) - } + uninstallNative(t, root) if _, err := os.Stat(root); !os.IsNotExist(err) { t.Fatal("installation directory remains") } @@ -109,7 +107,8 @@ func TestUninstallFailureCanBeRerun(t *testing.T) { if err := runInstall(rc, args); err != nil { t.Fatal(err) } - locked := filepath.Join(root, "components", "locked") + t.Setenv("OAC_RUNTIME_HOME", root) + locked := filepath.Join(root, "capabilities", "locked") if err := os.MkdirAll(locked, 0700); err != nil { t.Fatal(err) } @@ -157,13 +156,13 @@ func TestInstallRefusesDirectoryHoldingOtherFiles(t *testing.T) { // Without --install-dir an installation gets its own directory in the Runtime home. func TestInstallDefaultsToEnvironmentDirectory(t *testing.T) { - rc, args, root, _ := nativeInstallFixture(t) + rc, args, _, _ := nativeInstallFixture(t) args = slices.Delete(args, slices.Index(args, "--install-dir"), slices.Index(args, "--install-dir")+2) if err := runInstall(rc, args); err != nil { t.Fatal(err) } environment := args[slices.Index(args, "--environment-id")+1] - if _, err := os.Stat(filepath.Join(root, "environments", environment, "daemon", "installation.json")); err != nil { + if _, err := os.Stat(filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "environments", environment, "daemon", "installation.json")); err != nil { t.Fatalf("installation is not in the default directory: %v", err) } } diff --git a/apps/daemon/internal/cli/placement.go b/apps/daemon/internal/cli/placement.go deleted file mode 100644 index af2b661c4..000000000 --- a/apps/daemon/internal/cli/placement.go +++ /dev/null @@ -1,69 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/placement" -) - -func runPlacement(ctx *runContext, args []string) error { - if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { - fmt.Fprintln(ctx.stdout, "Usage: oac-daemon placement enroll --container --owner --workspace [--environment ]") - fmt.Fprintln(ctx.stdout, " oac-daemon placement retire --container [--environment ]") - fmt.Fprintln(ctx.stdout, "Explicit operator-managed local Linux/Docker only; normal harness release is unaffected.") - fmt.Fprintln(ctx.stdout, "Enrollment requires label io.oac.placement= and the qualified private profile.") - fmt.Fprintln(ctx.stdout, "Scoped enrollment requires the same --environment on retirement; this is operator consent, not Core authentication.") - return nil - } - action := args[0] - if action != "enroll" && action != "retire" { - return fmt.Errorf("unknown placement action %q", action) - } - fs := newFlagSet("placement " + action) - id := fs.String("container", "", "full immutable container ID") - environment := fs.String("environment", "", "operator-confirmed Environment UUID") - var owner, workspace string - if action == "enroll" { - fs.StringVar(&owner, "owner", "", "operator-created placement label value") - fs.StringVar(&workspace, "workspace", "", "retained absolute host workspace path") - } - if err := fs.Parse(args[1:]); err != nil { - if err == flag.ErrHelp { - return runPlacement(ctx, []string{"--help"}) - } - return err - } - if fs.NArg() != 0 || *id == "" { - return fmt.Errorf("placement %s requires --container and no positional arguments", action) - } - scoped := false - fs.Visit(func(f *flag.Flag) { - if f.Name == "environment" { - scoped = true - } - }) - controller, err := placement.New() - if err != nil { - return err - } - operation, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - var receipt *placement.Receipt - if action == "enroll" && scoped { - receipt, err = controller.EnrollEnvironment(operation, *id, owner, workspace, *environment) - } else if action == "enroll" { - receipt, err = controller.Enroll(operation, *id, owner, workspace) - } else if scoped { - receipt, err = controller.RetireEnvironment(operation, *id, *environment) - } else { - receipt, err = controller.Retire(operation, *id) - } - if err != nil { - return err - } - return json.NewEncoder(ctx.stdout).Encode(receipt) -} diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 092c62946..a2bcdad44 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -40,7 +40,6 @@ var commands = []command{ {name: "uninstall", summary: "Stop the daemon and delete this installation", run: runUninstall}, {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: "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}, diff --git a/apps/daemon/internal/cli/root_test.go b/apps/daemon/internal/cli/root_test.go index 2d930fc40..68a8cca4e 100644 --- a/apps/daemon/internal/cli/root_test.go +++ b/apps/daemon/internal/cli/root_test.go @@ -65,7 +65,6 @@ func TestSubcommandsAreRegistered(t *testing.T) { "uninstall": false, "resume": false, "runtime-mcp-exec": false, - "placement": false, "connect": false, "status": false, "stop": false, diff --git a/apps/daemon/internal/daemonize/fork_unix.go b/apps/daemon/internal/daemonize/fork_unix.go index 142ba56c7..d729a25be 100644 --- a/apps/daemon/internal/daemonize/fork_unix.go +++ b/apps/daemon/internal/daemonize/fork_unix.go @@ -3,38 +3,11 @@ package daemonize import ( - "fmt" - "os" "os/exec" - "strconv" "syscall" ) func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { - if err := closeInheritedOnExec(); err != nil { - return nil, nil, err - } cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} return func() {}, func() error { return nil }, nil } - -// closeInheritedOnExec keeps descriptors this process inherited, such as the -// native bootstrap's download lock, out of the detached daemon, which outlives -// its caller. Go opens its own descriptors close-on-exec already. -func closeInheritedOnExec() error { - dir, err := os.Open("/dev/fd") - if err != nil { - return fmt.Errorf("daemonize: list open descriptors: %w", err) - } - names, err := dir.Readdirnames(-1) - dir.Close() - if err != nil { - return fmt.Errorf("daemonize: list open descriptors: %w", err) - } - for _, name := range names { - if fd, err := strconv.Atoi(name); err == nil && fd > 2 { - syscall.CloseOnExec(fd) - } - } - return nil -} diff --git a/apps/daemon/internal/daemonize/fork_unix_test.go b/apps/daemon/internal/daemonize/fork_unix_test.go deleted file mode 100644 index a3caf9f55..000000000 --- a/apps/daemon/internal/daemonize/fork_unix_test.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build unix - -package daemonize - -import ( - "path/filepath" - "syscall" - "testing" - "time" - - "golang.org/x/sys/unix" -) - -// The native bootstrap holds its download lock on an inheritable descriptor. -// The detached daemon must not keep that lock after its caller exits. -func TestSpawnLeavesInheritedLockBehind(t *testing.T) { - dir := privateTempDir(t) - lockPath := filepath.Join(dir, "download.lock") - inherited, err := syscall.Open(lockPath, syscall.O_RDWR|syscall.O_CREAT, 0o600) - if err != nil { - t.Fatal(err) - } - if err := unix.Flock(inherited, unix.LOCK_EX|unix.LOCK_NB); err != nil { - t.Fatal(err) - } - t.Setenv(spawnTestChildEnv, "1") - pidPath := filepath.Join(dir, "child.pid") - if _, err := Spawn([]string{"oac-daemon", "child-mode"}, ReExecOptions{LogPath: filepath.Join(dir, "child.log"), PIDPath: pidPath}); err != nil { - t.Fatalf("Spawn: %v", err) - } - t.Cleanup(func() { _ = StopPIDFile(pidPath, 2*time.Second) }) - if err := syscall.Close(inherited); err != nil { - t.Fatal(err) - } - - next, err := syscall.Open(lockPath, syscall.O_RDWR|syscall.O_CLOEXEC, 0) - if err != nil { - t.Fatal(err) - } - defer syscall.Close(next) - if err := unix.Flock(next, unix.LOCK_EX|unix.LOCK_NB); err != nil { - t.Fatalf("the background child kept the caller's lock: %v", err) - } -} diff --git a/apps/daemon/internal/dispatch/capability_admission_test.go b/apps/daemon/internal/dispatch/capability_admission_test.go index 7aaf78466..b12cd0b4b 100644 --- a/apps/daemon/internal/dispatch/capability_admission_test.go +++ b/apps/daemon/internal/dispatch/capability_admission_test.go @@ -23,11 +23,11 @@ func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementatio return fmt.Errorf("%w: fixture has no active input", agent.ErrUnsupportedOperation) }}, nil }) - info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilityFromBool(supported)})} + info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: 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) + info.Capabilities.DurableInputReceipts = proto.CapabilityFromBool(!supported) 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 { diff --git a/apps/daemon/internal/dispatch/durable_only_test.go b/apps/daemon/internal/dispatch/durable_only_test.go deleted file mode 100644 index 1c6fa14ac..000000000 --- a/apps/daemon/internal/dispatch/durable_only_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package dispatch_test - -import ( - "context" - "sync/atomic" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type durableOnlyExecutor struct { - *reusableExecutor - applied atomic.Int32 -} - -func (e *durableOnlyExecutor) StartTurn(ctx context.Context, id string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - turn, err := e.reusableExecutor.StartTurn(ctx, id, input, out) - return &durableOnlyTurn{Turn: turn, applied: &e.applied}, err -} - -type durableOnlyTurn struct { - agent.Turn - applied *atomic.Int32 -} - -func (t *durableOnlyTurn) SteerWithReceipt(_ context.Context, _ proto.PromptSteerPayload, written func()) error { - written() - t.applied.Add(1) - return nil -} - -func TestPublicTextSteeringDoesNotRequireOptionalSteerer(t *testing.T) { - owner := &durableOnlyExecutor{reusableExecutor: &reusableExecutor{starts: make(chan *reusableTurn, 1)}} - router, sender := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) - admission := executorAdmission(t, router, sender, "prepare", executorRequest()) - startExecutorTurn(t, router, sender, "prepare", "run", admission) - turn := <-owner.starts - defer turn.finish() - input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("follow-up"), DurableReceipt: true} - if err := router.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { - t.Fatal(err) - } - waitFor(t, func() bool { - for _, event := range sender.snapshot() { - var ack proto.PromptSteerAckPayload - if event.Type == proto.TypePromptSteerAck && event.DecodePayload(&ack) == nil && ack.InputID == input.InputID && ack.Accepted { - return true - } - } - return false - }, "durable input applied without optional Steerer") - if owner.applied.Load() != 1 { - t.Fatal("input was not applied exactly once") - } - input.DurableReceipt = false - input.InputID = "ordinary" - if err := router.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, sender, "run", "ordinary"); ack.ErrorCode != "unsupported" { - t.Fatal("unimplemented optional Steerer was not rejected") - } - -} diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index 74de20411..5c120ca87 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -13,9 +13,6 @@ func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.Age 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 } diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 5b2e94305..113458736 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -44,7 +44,7 @@ func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { 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.AgentStateKey != "agents-api-"+input.SessionID { + if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.AgentStateKey != input.SessionID { return r.rejectPreparation(env, "invalid_configuration") } caps, available := r.availableCapabilities(req.AgentKind) diff --git a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index d9cf60fac..ecfdde7a0 100644 --- a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -103,9 +103,6 @@ func (t *receiptCancelTurn) AwaitSettlement(ctx context.Context) (agent.TurnSett func (t *receiptCancelTurn) CancellationOutcome() proto.DonePayload { return proto.DonePayload{Content: "observed", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} } -func (t *receiptCancelTurn) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return t.SteerWithReceipt(ctx, input, nil) -} func (t *receiptCancelTurn) SteerWithReceipt(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { written() close(t.written) @@ -156,7 +153,7 @@ func TestExecutorCancellationReachesNativeBeforeDurableReceiptJoin(t *testing.T) p := executorAdmission(t, r, sender.recSender, "first", executorRequest()) startExecutorTurn(t, r, sender.recSender, "first", "run", p) turn := <-owner.turn - input := proto.PromptSteerPayload{InputID: "pending", Input: proto.TextInput("second input"), DurableReceipt: true} + input := proto.PromptSteerPayload{InputID: "pending", Input: proto.TextInput("second input")} if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 0b679aa7f..31f0b9ed3 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -147,7 +147,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } } } - request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", DisableExecutionEnvironment: true}} + request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "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 2cba36047..e5ed86a39 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -71,7 +71,7 @@ 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 + config.AgentStateKey, config.DisableExecutionEnvironment = session, true return proto.ExecutionPreparePayload{SessionID: session, Configuration: config} } func executorRequest() proto.ExecutionPreparePayload { @@ -247,7 +247,7 @@ func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router, func poolRequest(id string) proto.ExecutionPreparePayload { req := executorRequest() req.SessionID = id - req.Configuration.AgentStateKey = "agents-api-" + id + req.Configuration.AgentStateKey = id return req } diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 8319fbecc..7ac10720f 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -40,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, WorkspaceReadOnly: true} + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: session, WorkspaceReadOnly: true} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil { t.Fatal(err) } @@ -67,7 +67,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing t.Fatal("read preparation admitted execution") } bad := request - bad.AgentStateKey = "agents-api-" + uuid.NewString() + bad.AgentStateKey = uuid.NewString() if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "wrong-session", proto.ExecutionPreparePayload{Configuration: bad})); err == nil { t.Fatal("wrong Session accepted") } @@ -118,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, WorkspaceReadOnly: true} + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: 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 3ba64f237..ff6805d3e 100644 --- a/apps/daemon/internal/dispatch/mcp_http_test.go +++ b/apps/daemon/internal/dispatch/mcp_http_test.go @@ -96,7 +96,6 @@ func TestLocalMCPOriginAndCapabilityAdmission(t *testing.T) { req.Configuration.MCPHTTPServers = &servers if strings.HasPrefix(mode, "environment") { servers[0].ConnectionOrigin = "environment" - req.Configuration.LocalEnvironment.NetworkAccess = "enabled" } if strings.Contains(mode, "bearer") { token := "synthetic-private-token" diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index 0ab84ce26..73206099f 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -107,19 +107,10 @@ func (t *preparationTurn) SubmitFunctionResult(ctx context.Context, p proto.Func } return agent.ErrUnknownFunctionCall } -func (t *preparationTurn) Steer(ctx context.Context, p proto.PromptSteerPayload) error { - if target, ok := t.Session.(agent.Steerer); ok { - return target.Steer(ctx, p) - } - return agent.ErrSteeringInactive -} func (t *preparationTurn) SteerWithReceipt(ctx context.Context, p proto.PromptSteerPayload, write func()) error { if target, ok := t.Session.(agent.DurableSteerer); ok { return target.SteerWithReceipt(ctx, p, write) } - if target, ok := t.Session.(agent.Steerer); ok { - return target.Steer(ctx, p) - } return agent.ErrSteeringInactive } func (t *preparationTurn) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index d987fc818..41f97ded1 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -86,8 +86,6 @@ func preparationWorkspace(t *testing.T) *localworkspace.Binding { "OAC_RUNTIME_WORKSPACE": t.TempDir(), "OAC_RUNTIME_CAPABILITY_DIRECTORY": t.TempDir(), "OAC_RUNTIME_HOME": runtimeHome, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - "OAC_RUNTIME_ALLOWED_DOMAINS": "", } { t.Setenv(name, value) } @@ -113,13 +111,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, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: preparationSessionID, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} } 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, WorkspaceReadPreparation: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, FunctionTools: 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 { diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 9e86b0168..39c557936 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -30,7 +30,7 @@ func (s *preparedMutationSession) SubmitFunctionResult(context.Context, proto.Fu return nil } -func (s *preparedMutationSession) Steer(context.Context, proto.PromptSteerPayload) error { +func (s *preparedMutationSession) SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error { s.steers.Add(1) return nil } @@ -278,4 +278,3 @@ func TestPreparedHandoffEarlyDonePublishesAfterStarted(t *testing.T) { } var _ agent.FunctionResultSubmitter = (*preparedMutationSession)(nil) -var _ agent.Steerer = (*preparedMutationSession)(nil) diff --git a/apps/daemon/internal/dispatch/receipt_order_test.go b/apps/daemon/internal/dispatch/receipt_order_test.go index 13d3edf50..847e216d9 100644 --- a/apps/daemon/internal/dispatch/receipt_order_test.go +++ b/apps/daemon/internal/dispatch/receipt_order_test.go @@ -50,7 +50,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { registry := agent.NewRegistry() var session *fakeSession var calls atomic.Int32 - 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) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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) @@ -128,7 +128,7 @@ func TestShutdownReleasesSteeringWorkerAndReceiptJoin(t *testing.T) { registry := agent.NewRegistry() entered, exited := make(chan struct{}), make(chan struct{}) var session *fakeSession - 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) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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) diff --git a/apps/daemon/internal/dispatch/receipt_shutdown_test.go b/apps/daemon/internal/dispatch/receipt_shutdown_test.go index e736bf867..42ff7efaa 100644 --- a/apps/daemon/internal/dispatch/receipt_shutdown_test.go +++ b/apps/daemon/internal/dispatch/receipt_shutdown_test.go @@ -45,7 +45,7 @@ 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 - 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) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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 })) diff --git a/apps/daemon/internal/dispatch/steering.go b/apps/daemon/internal/dispatch/steering.go index fa71133ac..cde7c7725 100644 --- a/apps/daemon/internal/dispatch/steering.go +++ b/apps/daemon/internal/dispatch/steering.go @@ -22,7 +22,6 @@ const ( type steeringReceipt struct { fingerprint [32]byte ack proto.PromptSteerAckPayload - durable bool } func (r *Router) handlePromptSteer(ctx context.Context, env proto.Envelope) error { @@ -89,7 +88,7 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr encoded, _ := json.Marshal(input.Input) fingerprint := sha256.Sum256(encoded) if previous, ok := state.steering[input.InputID]; ok { - if previous.fingerprint != fingerprint || previous.durable != input.DurableReceipt { + if previous.fingerprint != fingerprint { ack.ErrorCode, ack.Error = "input_conflict", "This input ID was already used with different text." return &ack } @@ -110,24 +109,18 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr ack.ErrorCode, ack.Error = "not_ready", "The run is still starting." // Bind input identity before any retryable state so changed text cannot // slip through a startup or in-flight retry. - state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack} if state.session == nil { return &ack } // The admitted declaration is immutable even if discovery changes later. - if input.DurableReceipt && !state.capabilities.DurableInputReceipts.IsSupported() || !input.DurableReceipt && !state.capabilities.Steering.IsSupported() { + if !state.capabilities.DurableInputReceipts.IsSupported() { ack.ErrorCode, ack.Error = "unsupported", "The runtime declaration does not support this input operation." return &ack } - session := state.session - steerer, supportsSteering := session.(agent.Steerer) - if !input.DurableReceipt && !supportsSteering { - ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." - return &ack - } if state.steerBusy { ack.ErrorCode, ack.Error = "busy", "Another input is awaiting an engine receipt; retry this input later." - state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack} return &ack } session, finishOperation, ready := r.preparedOperationLocked(state) @@ -136,7 +129,7 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr return &ack } 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.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack} state.steerBusy = true r.shutdownWG.Add(1) go func() { @@ -149,14 +142,7 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr }() ctx, stop := r.shutdownContext(ctx) defer stop() - var err error - if input.DurableReceipt { - err = r.steerDurably(ctx, state, session, env, input, fingerprint) - } else { - callCtx, cancel := context.WithTimeout(ctx, steeringCallTimeout) - err = steerer.Steer(callCtx, input) - cancel() - } + err := r.steerDurably(ctx, state, session, env, input, fingerprint) r.publishSteeringReceipt(ctx, state, env, input, fingerprint, steeringResult(input.InputID, err)) }() return nil diff --git a/apps/daemon/internal/dispatch/steering_lifetime.go b/apps/daemon/internal/dispatch/steering_lifetime.go index 93a59ebb2..4e1f4e83a 100644 --- a/apps/daemon/internal/dispatch/steering_lifetime.go +++ b/apps/daemon/internal/dispatch/steering_lifetime.go @@ -32,7 +32,7 @@ func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, sess func (r *Router) publishSteeringReceipt(ctx context.Context, state *sessionState, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte, ack proto.PromptSteerAckPayload) bool { r.mu.Lock() - state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack} r.mu.Unlock() // Credit waits follow the Run/connection lifetime, not a short native-call // budget. Dropping an accepted receipt would make its outcome unknown. diff --git a/apps/daemon/internal/dispatch/steering_lifetime_test.go b/apps/daemon/internal/dispatch/steering_lifetime_test.go index 9a2419768..bb162e544 100644 --- a/apps/daemon/internal/dispatch/steering_lifetime_test.go +++ b/apps/daemon/internal/dispatch/steering_lifetime_test.go @@ -26,7 +26,7 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { var session *fakeSession var calls atomic.Int32 release := make(chan struct{}) - 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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) @@ -41,7 +41,7 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { })) 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} + input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("additional")} env := mustEnv(t, proto.TypePromptSteer, "durable", input) if err := handleSteeringAndWait(t, h, env); err != nil { t.Fatal(err) @@ -78,7 +78,7 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) exited := make(chan struct{}) - 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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,7 +91,7 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { ctx := context.Background() 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 { + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text")})); err != nil { t.Fatal(err) } if phase == "blocked-write" { diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go index 1c14917fd..59b6df2e7 100644 --- a/apps/daemon/internal/dispatch/steering_test.go +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -17,7 +17,7 @@ type steeringSession struct { steer func(context.Context, proto.PromptSteerPayload) error } -func (s *steeringSession) Steer(ctx context.Context, input proto.PromptSteerPayload) error { +func (s *steeringSession) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, _ func()) error { return s.steer(ctx, input) } @@ -31,15 +31,12 @@ func TestSteeringReceiptsAndRetries(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls, starts := 0, 0 - 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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}, steer: func(ctx context.Context, input proto.PromptSteerPayload) error { calls++ - if _, ok := ctx.Deadline(); !ok { - t.Error("native request has no deadline") - } if input.InputID != "input-1" || *input.Input[0].Content[0].Text != "additional text" { t.Errorf("input lost: %+v", input) } @@ -90,7 +87,7 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls := 0 - 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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 { @@ -164,7 +161,7 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { defer h.router.Shutdown(context.Background()) entered, release := make(chan struct{}), make(chan struct{}) defer close(release) - 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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 { @@ -232,7 +229,7 @@ func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls := 0 - 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) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: 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 { diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index 9d24e3555..1dfdd84e5 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -10,14 +10,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { environment string - networkAccess string - allowedDomains []string stateKey string workspace string writer *fileWriter @@ -42,13 +39,8 @@ func NewWithCapabilityDirectory(environment, session, workspace, directory strin func Load() (*Binding, error) { values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE")} - policy, err := RuntimeNetworkPolicy() - if err != nil { - return nil, err - } - network := policy.Access capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if strings.Join(values, "") == "" && network == "" && capabilityDirectory == "" { + if strings.Join(values, "") == "" && capabilityDirectory == "" { return nil, nil } if capabilityDirectory == "" { @@ -58,9 +50,6 @@ func Load() (*Binding, error) { if err != nil { return nil, err } - b.networkAccess = network - b.allowedDomains = policy.Hosts() - return b, nil } @@ -73,12 +62,6 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa 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 { - requested := agentnetwork.Policy{Access: r.LocalEnvironment.NetworkAccess, AllowedDomains: r.LocalEnvironment.AllowedDomains} - if !b.NetworkPolicy().Equal(requested) { - return r, errors.New("request does not match the local Runtime network policy") - } - } if !r.WorkspaceReadOnly { local := *r.LocalEnvironment if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { @@ -100,5 +83,5 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } func (b *Binding) Matches(environment, session string) bool { - return b != nil && b.environment == environment && b.stateKey == "agents-api-"+session + return b != nil && b.environment == environment && b.stateKey == session } diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index b23ea8c0f..321f092b3 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -24,8 +24,7 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { if err != nil { t.Fatal(err) } - b.networkAccess = "disabled" - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session} + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: session} } func TestBindingRejectsScopeOverrides(t *testing.T) { @@ -39,7 +38,7 @@ 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() }, + "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = uuid.NewString() }, "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, } { t.Run(name, func(t *testing.T) { diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index 3dd410248..4199aac3f 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -56,10 +56,16 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr return r, err } } - if len(manifest.MCP) != 0 { - if b.NetworkPolicy().Access != "enabled" { - return r, agentcapabilities.ErrInvalid + if r.MCPHTTPServers != nil { + values, readErr := b.ReadToolEnvironment() + if readErr == nil { + readErr = requireDeclaredVariables(*r.MCPHTTPServers, values) + } + if readErr != nil { + return r, readErr } + } + if len(manifest.MCP) != 0 { values, readErr := b.ReadToolEnvironment() if readErr != nil { return r, readErr @@ -144,6 +150,10 @@ func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities. return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid } manifest, err := agentcapabilities.Load(root) + if err == nil && !completed && manifest.Identity != identity { + // A preparation restored from an identical earlier Session carries that Session's identity. + err = agentcapabilities.Rebind(root, &manifest, input, identity) + } if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid } @@ -151,7 +161,7 @@ func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities. } func (b *Binding) capabilityIdentity() agentcapabilities.Identity { - return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: strings.TrimPrefix(b.stateKey, "agents-api-")} + return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: b.stateKey} } // The current Linux Runtime layout is shared by user-owned and managed hosts. diff --git a/apps/daemon/internal/localworkspace/capability_preparation_test.go b/apps/daemon/internal/localworkspace/capability_preparation_test.go index dc061b085..8bb807e19 100644 --- a/apps/daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/daemon/internal/localworkspace/capability_preparation_test.go @@ -44,7 +44,6 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { if err != nil { t.Fatal(err) } - reconnect.networkAccess = b.networkAccess again, err := reconnect.Prepare(t.Context(), configured) if err != nil || len(again.LocalEnvironment.Skills) != 1 { t.Fatalf("reconnection: %v", err) @@ -67,20 +66,20 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { if err != nil || string(fresh[len(fresh)-6:]) != "second" { t.Fatal("new Session did not capture new source", err) } - // Reusing a snapshot under another identity or selection cannot start native work. - reconnect.stateKey = "agents-api-" + uuid.NewString() - changed := configured - changed.AgentStateKey = reconnect.stateKey - if _, err = reconnect.Prepare(t.Context(), changed); err == nil { - t.Fatal("foreign snapshot accepted") - } - changed = configured + // Reusing a snapshot under another selection cannot start native work, even + // for another Session. local := *configured.LocalEnvironment local.CapabilitySources = &agentcapabilities.Input{} + changed := configured changed.LocalEnvironment = &local if _, err = b.Prepare(t.Context(), changed); err == nil { t.Fatal("changed selection accepted") } + reconnect.stateKey = uuid.NewString() + changed.AgentStateKey = reconnect.stateKey + if _, err = reconnect.Prepare(t.Context(), changed); err == nil { + t.Fatal("foreign snapshot accepted") + } } func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { @@ -209,7 +208,6 @@ func TestPreparationFreezesToolOnlyEnvironmentAcrossReconnect(t *testing.T) { if err != nil { t.Fatal(err) } - reconnect.networkAccess = b.networkAccess if _, err := reconnect.Prepare(t.Context(), configured); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/capability_rebind_test.go b/apps/daemon/internal/localworkspace/capability_rebind_test.go new file mode 100644 index 000000000..e1f4cbd3a --- /dev/null +++ b/apps/daemon/internal/localworkspace/capability_rebind_test.go @@ -0,0 +1,37 @@ +package localworkspace + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/google/uuid" +) + +func TestFinalizeRebindsIdenticalRestoredInstallation(t *testing.T) { + first, _ := testBinding(t) + source := t.TempDir() + writeSourceSkill(t, source, "restored") + input := agentcapabilities.Input{Directories: []string{source}} + root, unlock, err := first.openCapabilities(t.Context(), false) + if err != nil { + t.Fatal(err) + } + defer unlock() + if _, err = first.loadCapabilitySnapshot(root, input, first.capabilityIdentity(), false); err != nil { + t.Fatal(err) + } + // The next Session's directory holds the files restored from the first. + next, err := NewWithCapabilityDirectory(uuid.NewString(), uuid.NewString(), first.workspace, first.capabilityRoot) + if err != nil { + t.Fatal(err) + } + if _, err = next.loadCapabilitySnapshot(root, agentcapabilities.Input{}, next.capabilityIdentity(), false); err == nil { + t.Fatal("different selection accepted") + } + if _, err = next.loadCapabilitySnapshot(root, input, next.capabilityIdentity(), false); err != nil { + t.Fatal(err) + } + if manifest, err := agentcapabilities.Load(root); err != nil || manifest.Identity != next.capabilityIdentity() { + t.Fatal("identity not rebound", err) + } +} diff --git a/apps/daemon/internal/localworkspace/mcp.go b/apps/daemon/internal/localworkspace/mcp.go index 820c852e4..75725f694 100644 --- a/apps/daemon/internal/localworkspace/mcp.go +++ b/apps/daemon/internal/localworkspace/mcp.go @@ -45,3 +45,19 @@ func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values ma } return result, nil } + +// requireDeclaredVariables fails a public stdio server that names a variable the +// initialized env does not set, as resolveEnvironmentMCP does for installed servers. +func requireDeclaredVariables(servers []proto.MCPHTTPServer, values map[string]string) error { + for _, server := range servers { + if server.Stdio == nil { + continue + } + for _, name := range server.Stdio.EnvVars { + if _, exists := values[name]; !exists { + return errors.New("declared environment MCP variable unavailable") + } + } + } + return nil +} diff --git a/apps/daemon/internal/localworkspace/mcp_test.go b/apps/daemon/internal/localworkspace/mcp_test.go index d0e52e3cc..1ff8b336b 100644 --- a/apps/daemon/internal/localworkspace/mcp_test.go +++ b/apps/daemon/internal/localworkspace/mcp_test.go @@ -28,6 +28,14 @@ func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { } } +func TestDeclaredMCPVariablesNeverFallBackToNativeEnv(t *testing.T) { + t.Setenv("SERVER_TOKEN", "native-private-value") + servers := []proto.MCPHTTPServer{{ServerLabel: "local", Stdio: &proto.MCPStdio{Command: "run", Cwd: "/", EnvVars: []string{"SERVER_TOKEN"}}}} + if requireDeclaredVariables(servers, nil) == nil || requireDeclaredVariables(servers, map[string]string{"SERVER_TOKEN": ""}) != nil { + t.Fatal("env_vars did not resolve only from the initialized env") + } +} + func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, diff --git a/apps/daemon/internal/localworkspace/native_binding.go b/apps/daemon/internal/localworkspace/native_binding.go index 4805dba7b..02c57adae 100644 --- a/apps/daemon/internal/localworkspace/native_binding.go +++ b/apps/daemon/internal/localworkspace/native_binding.go @@ -25,7 +25,7 @@ func newNativeBinding(environment, session, workspace, capabilities string) (*Bi if err != nil || !info.IsDir() { return nil, errors.New("local workspace root must be an existing directory") } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil + return &Binding{environment: environment, stateKey: session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil } // ReadToolEnvironment reads explicit initialization values for this installation. diff --git a/apps/daemon/internal/localworkspace/network.go b/apps/daemon/internal/localworkspace/network.go deleted file mode 100644 index 220561590..000000000 --- a/apps/daemon/internal/localworkspace/network.go +++ /dev/null @@ -1,32 +0,0 @@ -package localworkspace - -import ( - "encoding/json" - "errors" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" -) - -// RuntimeNetworkPolicy reads deployment configuration, never caller options. -func RuntimeNetworkPolicy() (agentnetwork.Policy, error) { - policy := agentnetwork.Policy{Access: os.Getenv("OAC_RUNTIME_NETWORK_ACCESS")} - if raw := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); raw != "" { - if err := json.Unmarshal([]byte(raw), &policy.AllowedDomains); err != nil { - return policy, errors.New("invalid local Runtime network domains") - } - } - // Unbound runtimes may serve operations without a hosted workspace. - if policy.Access == "" && len(policy.AllowedDomains) == 0 { - return policy, nil - } - if err := policy.Validate(); err != nil { - return policy, errors.New("unsupported local Runtime network policy") - } - return policy, nil -} - -// NetworkPolicy returns a copy of the frozen execution authority. -func (b *Binding) NetworkPolicy() agentnetwork.Policy { - return agentnetwork.Policy{Access: b.networkAccess, AllowedDomains: append([]string(nil), b.allowedDomains...)} -} diff --git a/apps/daemon/internal/localworkspace/network_policy_test.go b/apps/daemon/internal/localworkspace/network_policy_test.go deleted file mode 100644 index 96a7a9d10..000000000 --- a/apps/daemon/internal/localworkspace/network_policy_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package localworkspace - -import ( - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" - "testing" -) - -func TestRuntimeNetworkPolicyMustMatchExecutionButNotReadOnly(t *testing.T) { - for _, deployed := range []string{"", "enabled", "disabled"} { - for _, requested := range []string{"", "enabled", "disabled", "restricted"} { - b, req := testBinding(t) - b.networkAccess = deployed - req.LocalEnvironment.NetworkAccess = requested - _, err := b.Configure(req) - if (err == nil) != (deployed != "" && deployed == requested) { - t.Fatalf("execution policy %q/%q: %v", deployed, requested, err) - } - req.WorkspaceReadOnly = true - req.LocalEnvironment = &proto.LocalEnvironment{ID: b.environment} - if _, err := b.Configure(req); err != nil { - t.Fatal("read-only operation requires unrelated execution policy", err) - } - } - } -} - -func TestRestrictedPolicyBindingCannotBeChangedByARequest(t *testing.T) { - b, req := testBinding(t) - b.networkAccess, b.allowedDomains = "restricted", []string{"example.com", "api.example.com"} - for _, domains := range [][]string{{"api.example.com", "EXAMPLE.com", "example.com"}, {"example.com"}, {"other.example.com"}, nil} { - req.LocalEnvironment.NetworkAccess = "restricted" - req.LocalEnvironment.AllowedDomains = domains - _, err := b.Configure(req) - if (err == nil) != (len(domains) == 3) { - t.Fatalf("binding changed by domains %v: %v", domains, err) - } - } - copy := b.NetworkPolicy() - copy.AllowedDomains[0] = "other.example.com" - if !b.NetworkPolicy().Equal(agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"example.com", "api.example.com"}}) { - t.Fatal("caller mutated frozen policy") - } - req.WorkspaceReadOnly = true - req.LocalEnvironment = &proto.LocalEnvironment{ID: b.environment} - if _, err := b.Configure(req); err != nil { - t.Fatal("read requires execution network", err) - } - req.LocalEnvironment.AllowedDomains = []string{"other.example.com"} - if _, err := b.Configure(req); err == nil { - t.Fatal("read accepted a conflicting supplied policy") - } -} - -func TestRuntimeNetworkPolicyRejectsMalformedDeploymentInput(t *testing.T) { - for _, tc := range []struct { - access, domains string - valid bool - }{ - {"restricted", `["example.com"]`, true}, - {"restricted", `[]`, false}, - {"restricted", `["*"]`, false}, - {"restricted", `"example.com"`, false}, - {"enabled", `["example.com"]`, false}, - {"", `["example.com"]`, false}, - {"disabled", `[]`, true}, - } { - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", tc.access) - t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", tc.domains) - if _, err := RuntimeNetworkPolicy(); (err == nil) != tc.valid { - t.Fatalf("%s/%s: %v", tc.access, tc.domains, err) - } - } -} diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index 05e7a6dc2..062f49330 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -16,7 +16,7 @@ const WriteMaxBytes = proto.WorkspaceWriteMaxBytes var _ agent.WorkspaceWriter = (*Binding)(nil) func (b *Binding) AcceptsFileWrite(environment, session string) bool { - return b != nil && b.writer != nil && b.environment == environment && b.stateKey == "agents-api-"+session + return b != nil && b.writer != nil && b.environment == environment && b.stateKey == session } // WriteWorkspaceFile starts only after the caller supplies the complete bounded diff --git a/apps/daemon/internal/placement/controller_linux.go b/apps/daemon/internal/placement/controller_linux.go deleted file mode 100644 index f31c0f378..000000000 --- a/apps/daemon/internal/placement/controller_linux.go +++ /dev/null @@ -1,211 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "time" -) - -var fullID = regexp.MustCompile(`^[a-f0-9]{64}$`) -var ownerID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) - -// Controller is the bounded local Linux/Docker retirement consumer. Its authority -// is the operator's private enrollment, never a public executor credential. -type Controller struct { - root string - run func(context.Context, ...string) ([]byte, error) - procRoot string - cgroupRoot string - socketPath string - syncDir func(string) error -} - -// New uses a fixed local Docker endpoint and private state beneath ~/.oac. -func New() (*Controller, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - return &Controller{root: filepath.Join(home, ".oac", "placements"), run: runDocker, - procRoot: "/proc", cgroupRoot: "/sys/fs/cgroup", socketPath: localDockerSocket, syncDir: syncDirectory}, nil -} - -func (c *Controller) enroll(ctx context.Context, id, owner, workspace, environment string) (*Receipt, error) { - if !ownerID.MatchString(owner) { - return nil, errors.New("invalid placement owner") - } - unlock, err := c.lock(ctx, id) - if err != nil { - return nil, err - } - defer unlock() - if _, err := os.Lstat(c.recordPath(id)); !errors.Is(err, os.ErrNotExist) { - return nil, errors.New("placement already enrolled or record unavailable; use retire to reconcile") - } - unit, err := c.inspect(ctx, id) - if err != nil { - return nil, err - } - if !unit.State.Running { - return nil, errors.New("enrollment requires a running placement") - } - if err := c.validateProfile(unit, owner, workspace); err != nil { - return nil, err - } - host, supervisor, err := c.host(ctx) - if err != nil { - return nil, err - } - init, _, err := c.process(unit.State.Pid) - if err != nil { - return nil, err - } - group, err := c.group(init.PID, id) - if err != nil { - return nil, err - } - members, err := c.members(group) - if err != nil { - return nil, err - } - if len(members) == 0 { - return nil, errors.New("running placement has no observed members") - } - version := 1 - if environment != "" { - version = 2 - } - r := &Receipt{Version: version, EnvironmentID: environment, State: "enrolled", Owner: owner, RequestedAt: time.Now().UTC(), - Members: members, Target: Target{HostBootID: host, Supervisor: supervisor, Container: id, - Created: unit.Created, Started: unit.State.StartedAt, Restarts: unit.RestartCount, - Image: unit.Image, Workspace: workspace, Cgroup: group, Init: init}} - if err := c.save(r); err != nil { - return nil, err - } - return r, nil -} - -func (c *Controller) retirePlacement(ctx context.Context, id, environment string) (*Receipt, error) { - unlock, err := c.lock(ctx, id) - if err != nil { - return nil, err - } - defer unlock() - r, err := c.load(id) - if err != nil { - return nil, err - } - if r.EnvironmentID != environment { - return nil, errors.New("placement Environment does not match enrollment") - } - if r.State == "retired" { - // A previous process may have published the rename without completing - // its directory sync. Finish that barrier before recovering success. - if err := c.syncDir(c.root); err != nil { - return nil, fmt.Errorf("placement receipt durability unknown: %w", err) - } - return r, nil - } - err = c.retire(ctx, r) - if err != nil { - return r, fmt.Errorf("placement retirement unknown: %w", err) - } - return r, nil -} - -func (c *Controller) retire(ctx context.Context, r *Receipt) error { - host, supervisor, err := c.host(ctx) - if err != nil { - return err - } - if host != r.Target.HostBootID || supervisor != r.Target.Supervisor { - return errors.New("local supervisor incarnation changed") - } - unit, err := c.inspect(ctx, r.Target.Container) - if err != nil { - return err - } - if err := c.validateProfile(unit, r.Owner, r.Target.Workspace); err != nil { - return err - } - if unit.Created != r.Target.Created || unit.Image != r.Target.Image || - unit.State.StartedAt != r.Target.Started || unit.RestartCount != r.Target.Restarts { - return errors.New("container incarnation changed") - } - if unit.State.Running { - init, _, err := c.process(unit.State.Pid) - if err != nil { - return err - } - if init != r.Target.Init { - return errors.New("container init identity changed") - } - group, err := c.group(init.PID, unit.ID) - if err != nil || group != r.Target.Cgroup { - return errors.New("container cgroup changed") - } - members, err := c.members(group) - if err != nil { - return err - } - r.Members = append(r.Members, members...) - } - // The immutable target and current members are durable before any stop. - r.State = "stopping" - if err := c.save(r); err != nil { - return err - } - if unit.State.Running { - if _, err := c.run(ctx, "container", "stop", "--time", "1", r.Target.Container); err != nil { - return err - } - } - unit, err = c.inspect(ctx, r.Target.Container) - if err != nil { - return err - } - if unit.State.Running || unit.State.Pid != 0 || unit.State.StartedAt != r.Target.Started || - unit.RestartCount != r.Target.Restarts { - return errors.New("container is live or restarted") - } - if err := c.observeRetired(r); err != nil { - return err - } - // Non-forced removal fails if an external operator restarted the unit. No - // volumes are deleted. A crash between removal and save stays unknown. - if _, err := c.run(ctx, "container", "rm", r.Target.Container); err != nil { - return err - } - r.State = "retired" - now := time.Now().UTC() - r.RetiredAt = &now - if err := c.save(r); err != nil { - r.State = "stopping" - r.RetiredAt = nil - return err - } - return nil -} - -func (c *Controller) host(ctx context.Context) (string, string, error) { - boot, err := os.ReadFile(filepath.Join(c.procRoot, "sys/kernel/random/boot_id")) - if err != nil { - return "", "", err - } - out, err := c.run(ctx, "info", "--format", "{{json .ID}}") - if err != nil { - return "", "", err - } - var id string - if err := json.Unmarshal(out, &id); err != nil || id == "" || len(boot) == 0 { - return "", "", errors.New("missing supervisor identity") - } - return string(boot), id, nil -} diff --git a/apps/daemon/internal/placement/controller_linux_test.go b/apps/daemon/internal/placement/controller_linux_test.go deleted file mode 100644 index 16e5d2026..000000000 --- a/apps/daemon/internal/placement/controller_linux_test.go +++ /dev/null @@ -1,411 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "sync" - "testing" - "time" -) - -const testID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - -type fixture struct { - c *Controller - unit *container - workspace string - stops, removals int - fail string - t *testing.T -} - -func writeTestFile(t *testing.T, path, data string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(data), 0600); err != nil { - t.Fatal(err) - } -} - -func newFixture(t *testing.T) *fixture { - t.Helper() - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - base := filepath.Join(home, ".oac", "placement-tests") - if err := os.MkdirAll(base, 0700); err != nil { - t.Fatal(err) - } - dir, err := os.MkdirTemp(base, "case-") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - f := &fixture{t: t, workspace: filepath.Join(dir, "workspace")} - if err := os.Mkdir(f.workspace, 0700); err != nil { - t.Fatal(err) - } - f.c = &Controller{root: filepath.Join(dir, "state"), procRoot: filepath.Join(dir, "proc"), cgroupRoot: filepath.Join(dir, "cgroup"), socketPath: filepath.Join(dir, "run/docker.sock"), syncDir: syncDirectory} - f.c.run = f.run - writeTestFile(t, f.c.socketPath, "fake supervisor socket") - f.unit = &container{ID: testID, Created: "created-1", Image: "sha256:image"} - f.unit.State.Running = true - f.unit.State.Pid = 123 - f.unit.State.StartedAt = "start-1" - f.unit.Config.User = "1000:1000" - f.unit.Config.Labels = map[string]string{BindingLabel: "owner-1"} - f.unit.HostConfig.NetworkMode = "none" - f.unit.HostConfig.IpcMode = "private" - f.unit.HostConfig.CgroupnsMode = "private" - f.unit.HostConfig.CapDrop = []string{"ALL"} - f.unit.HostConfig.SecurityOpt = []string{"no-new-privileges"} - f.unit.HostConfig.RestartPolicy.Name = "no" - f.unit.Mounts = append(f.unit.Mounts, struct { - Type, Source, Destination, Propagation string - RW bool - }{Type: "bind", Source: f.workspace, Destination: "/workspace", Propagation: "rprivate", RW: true}) - writeTestFile(t, filepath.Join(f.c.procRoot, "sys/kernel/random/boot_id"), "boot-1") - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n") - writeTestFile(t, filepath.Join(f.c.procRoot, "123/stat"), "123 (native (worker)) S "+strings.Repeat("0 ", 18)+"999 0") - writeTestFile(t, filepath.Join(f.c.procRoot, "123/cgroup"), "0::/docker-"+testID+".scope\n") - writeTestFile(t, filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope/cgroup.procs"), "123\n") - writeTestFile(t, filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope/cgroup.events"), "populated 1\n") - return f -} - -func (f *fixture) run(_ context.Context, args ...string) ([]byte, error) { - if args[0] == "info" { - if f.fail == "supervisor" { - return nil, errors.New("unavailable") - } - return []byte(`"supervisor-1"`), nil - } - switch args[1] { - case "inspect": - if f.unit == nil { - return nil, os.ErrNotExist - } - return json.Marshal([]container{*f.unit}) - case "stop": - r, err := f.c.load(testID) - if err != nil || r.State != "stopping" || len(r.Members) == 0 { - f.t.Fatal("stop before durable intent", err) - } - f.stops++ - if f.fail == "stop" { - return nil, errors.New("stop unavailable") - } - f.unit.State.Running = false - f.unit.State.Pid = 0 - if f.fail != "live" && f.fail != "escaped" { - _ = os.RemoveAll(filepath.Join(f.c.procRoot, "123")) - _ = os.RemoveAll(filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope")) - } - if f.fail == "escaped" { - _ = os.RemoveAll(filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope")) - } - return nil, nil - case "rm": - f.removals++ - f.unit = nil - if f.fail == "lost-remove-ack" { - return nil, errors.New("controller lost removal acknowledgement") - } - return nil, nil - } - return nil, errors.New("unexpected Docker command") -} - -func (f *fixture) enroll() *Receipt { - f.t.Helper() - r, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace) - if err != nil { - f.t.Fatal(err) - } - return r -} - -func TestRetirementPersistsBeforeStopAndRecoversIdenticalReceipt(t *testing.T) { - f := newFixture(t) - f.enroll() - writeTestFile(t, filepath.Join(f.workspace, "history"), "retained") - r, err := f.c.Retire(context.Background(), testID) - if err != nil || r.State != "retired" { - t.Fatal(r, err) - } - fresh := *f.c - fresh.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("completed receipt must not need supervisor") - return nil, nil - } - again, err := fresh.Retire(context.Background(), testID) - if err != nil || !reflect.DeepEqual(r, again) { - t.Fatal("receipt changed across controller restart", err) - } - if f.stops != 1 || f.removals != 1 { - t.Fatal("repeated destructive action") - } - if data, err := os.ReadFile(filepath.Join(f.workspace, "history")); err != nil || string(data) != "retained" { - t.Fatal("history lost") - } -} - -func TestUnknownEvidenceNeverRemovesOrReportsRetired(t *testing.T) { - for _, failure := range []string{"supervisor", "stop", "live", "escaped", "missing", "restart", "boot", "lost-remove-ack"} { - t.Run(failure, func(t *testing.T) { - f := newFixture(t) - f.enroll() - f.fail = failure - switch failure { - case "missing": - f.unit = nil - case "restart": - f.unit.State.StartedAt = "start-2" - case "boot": - writeTestFile(t, filepath.Join(f.c.procRoot, "sys/kernel/random/boot_id"), "boot-2") - } - r, err := f.c.Retire(context.Background(), testID) - if err == nil || r.State == "retired" { - t.Fatal("uncertain retirement reported success", r, err) - } - persisted, err := f.c.load(testID) - if err != nil || persisted.State == "retired" { - t.Fatal("uncertainty lost", err) - } - if failure != "lost-remove-ack" && f.removals != 0 { - t.Fatal("removed without proof") - } - if failure == "lost-remove-ack" { - fresh := *f.c - r, err = fresh.Retire(context.Background(), testID) - if err == nil || r.State == "retired" { - t.Fatal("absence manufactured success") - } - } - }) - } -} - -func TestReconcileStoppedTargetAfterInterruptedController(t *testing.T) { - f := newFixture(t) - r := f.enroll() - r.State = "stopping" - if err := f.c.save(r); err != nil { - t.Fatal(err) - } - if _, err := f.run(context.Background(), "container", "stop"); err != nil { - t.Fatal(err) - } - fresh := *f.c - result, err := fresh.Retire(context.Background(), testID) - if err != nil || result.State != "retired" || f.stops != 1 { - t.Fatal(result, err) - } -} - -func TestConcurrentRetirementUsesOneDestructiveAttempt(t *testing.T) { - f := newFixture(t) - f.enroll() - var wg sync.WaitGroup - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - fresh := *f.c - r, err := fresh.Retire(context.Background(), testID) - if err != nil || r.State != "retired" { - t.Error(r, err) - } - }() - } - wg.Wait() - if f.stops != 1 || f.removals != 1 { - t.Fatal("duplicate destructive attempts") - } -} - -func TestTargetLockHonorsCancellation(t *testing.T) { - f := newFixture(t) - unlock, err := f.c.lock(context.Background(), testID) - if err != nil { - t.Fatal(err) - } - defer unlock() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) - defer cancel() - if _, err := f.c.lock(ctx, testID); !errors.Is(err, context.DeadlineExceeded) { - t.Fatal(err) - } -} - -func TestEnrollmentRejectsUnqualifiedAuthorityAndProfile(t *testing.T) { - for _, bad := range []string{"short-id", "wrong-owner", "privileged", "host-pid", "network", "capability", "security", "restart", "workspace", "state-mount", "socket-mount", "relative", "stopped"} { - t.Run(bad, func(t *testing.T) { - f := newFixture(t) - id, owner, workspace := testID, "owner-1", f.workspace - switch bad { - case "short-id": - id = "aaaa" - case "wrong-owner": - owner = "someone-else" - case "privileged": - f.unit.HostConfig.Privileged = true - case "host-pid": - f.unit.HostConfig.PidMode = "host" - case "network": - f.unit.HostConfig.NetworkMode = "bridge" - case "capability": - f.unit.HostConfig.CapAdd = []string{"SYS_ADMIN"} - case "security": - f.unit.HostConfig.SecurityOpt = append(f.unit.HostConfig.SecurityOpt, "seccomp=unconfined") - case "restart": - f.unit.HostConfig.RestartPolicy.Name = "always" - case "workspace": - f.unit.Mounts = nil - case "state-mount": - f.unit.Mounts[0].Source = filepath.Dir(f.c.root) - case "socket-mount": - f.unit.Mounts[0].Type = "volume" - case "relative": - workspace = "workspace" - case "stopped": - f.unit.State.Running = false - } - if _, err := f.c.Enroll(context.Background(), id, owner, workspace); err == nil { - t.Fatal("accepted unqualified enrollment") - } - if f.stops+f.removals != 0 { - t.Fatal("enrollment mutated supervisor") - } - }) - } -} - -func TestUntrustedStateIsRejected(t *testing.T) { - f := newFixture(t) - f.enroll() - if err := os.Chmod(f.c.recordPath(testID), 0644); err != nil { - t.Fatal(err) - } - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("accepted exposed authority") - } - if f.stops+f.removals != 0 { - t.Fatal("mutated supervisor before authorization") - } -} - -func TestBindingCannotBeOverwrittenOrAppliedToAnotherContainer(t *testing.T) { - f := newFixture(t) - f.enroll() - if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil { - t.Fatal("binding overwritten") - } - if _, err := f.c.Retire(context.Background(), strings.Repeat("b", 64)); err == nil { - t.Fatal("another target authorized") - } - f.unit.ID = strings.Repeat("b", 64) - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("supervisor target substitution accepted") - } - if f.stops+f.removals != 0 { - t.Fatal("wrong target mutated") - } -} - -func TestSymlinkedStateAndChangedProfileAreRejected(t *testing.T) { - f := newFixture(t) - f.enroll() - path := f.c.recordPath(testID) - if err := os.Rename(path, path+".original"); err != nil { - t.Fatal(err) - } - if err := os.Symlink(path+".original", path); err != nil { - t.Fatal(err) - } - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("symlinked authority accepted") - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Rename(path+".original", path); err != nil { - t.Fatal(err) - } - f.unit.HostConfig.Privileged = true - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("changed profile accepted") - } - if f.stops+f.removals != 0 { - t.Fatal("mutated unqualified placement") - } -} - -func TestRootAndCanonicalSupervisorSocketAreNeverExposed(t *testing.T) { - f := newFixture(t) - if !inside("/", f.c.root) || !inside("/", f.c.socketPath) { - t.Fatal("filesystem root hides protected descendants") - } - f.workspace = filepath.Dir(f.c.socketPath) - f.unit.Mounts[0].Source = f.workspace - alias := filepath.Join(filepath.Dir(f.c.root), "socket-alias") - if err := os.Symlink(f.c.socketPath, alias); err != nil { - t.Fatal(err) - } - f.c.socketPath = alias - if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil || !strings.Contains(err.Error(), "supervisor socket") { - t.Fatal("canonical socket exposed", err) - } - if f.stops+f.removals != 0 { - t.Fatal("unqualified supervisor mutated") - } -} - -func TestPublishedReceiptMustCompleteDirectorySyncOnRecovery(t *testing.T) { - f := newFixture(t) - f.enroll() - f.c.syncDir = func(path string) error { - r, err := f.c.load(testID) - if err != nil { - return err - } - if r.State == "retired" { - return errors.New("injected directory sync failure after rename") - } - return syncDirectory(path) - } - if r, err := f.c.Retire(context.Background(), testID); err == nil || r.State == "retired" { - t.Fatal("reported success before durable receipt", r, err) - } - published, err := f.c.load(testID) - if err != nil || published.State != "retired" { - t.Fatal("failure did not exercise published rename", published, err) - } - fresh := *f.c - fresh.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("receipt recovery must not mutate supervisor") - return nil, nil - } - if _, err := fresh.Retire(context.Background(), testID); err == nil { - t.Fatal("recovery skipped durability barrier") - } - synced := false - fresh.syncDir = func(path string) error { synced = true; return syncDirectory(path) } - recovered, err := fresh.Retire(context.Background(), testID) - if err != nil || !synced || !reflect.DeepEqual(recovered, published) { - t.Fatal("durable receipt recovery failed", recovered, err) - } - if f.stops != 1 || f.removals != 1 { - t.Fatal("repeated destructive action") - } -} diff --git a/apps/daemon/internal/placement/controller_other.go b/apps/daemon/internal/placement/controller_other.go deleted file mode 100644 index 2bd82f666..000000000 --- a/apps/daemon/internal/placement/controller_other.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !linux - -package placement - -import ( - "context" - "errors" -) - -// Controller requires the qualified local Linux supervisor profile. -type Controller struct{} - -// New rejects unqualified hosts before any supervisor operation. -func New() (*Controller, error) { - return nil, errors.New("placement retirement requires local Linux/Docker with cgroup v2") -} - -func (*Controller) enroll(context.Context, string, string, string, string) (*Receipt, error) { - return nil, errors.New("unsupported placement host") -} - -func (*Controller) retirePlacement(context.Context, string, string) (*Receipt, error) { - return nil, errors.New("unsupported placement host") -} diff --git a/apps/daemon/internal/placement/docker_linux.go b/apps/daemon/internal/placement/docker_linux.go deleted file mode 100644 index 1eb115f18..000000000 --- a/apps/daemon/internal/placement/docker_linux.go +++ /dev/null @@ -1,164 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" -) - -const localDockerSocket = "/var/run/docker.sock" - -// Do not inherit a remote Docker context while observing local /proc and cgroups. -func runDocker(ctx context.Context, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, "docker", append([]string{"--host", "unix://" + localDockerSocket}, args...)...) - for _, v := range os.Environ() { - if !strings.HasPrefix(v, "DOCKER_") { - cmd.Env = append(cmd.Env, v) - } - } - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("local Docker %s failed: %w", args[0], err) - } - return out, nil -} - -type container struct { - ID string `json:"Id"` - Created string - Image string - RestartCount int - State struct { - Running bool - Pid int - StartedAt string - Paused bool - Restarting bool - } - Config struct { - Labels map[string]string - User string - } - HostConfig struct { - Privileged bool - PidMode string - IpcMode string - CgroupnsMode string - NetworkMode string - CapAdd []string - CapDrop []string - SecurityOpt []string - Devices []json.RawMessage - DeviceRequests []json.RawMessage - DeviceCgroupRules []string - VolumesFrom []string - RestartPolicy struct{ Name string } - } - Mounts []struct { - Type, Source, Destination, Propagation string - RW bool - } -} - -func (c *Controller) inspect(ctx context.Context, id string) (*container, error) { - out, err := c.run(ctx, "container", "inspect", id) - if err != nil { - return nil, err - } - var units []container - if err := json.Unmarshal(out, &units); err != nil { - return nil, err - } - if len(units) != 1 || units[0].ID != id { - return nil, errors.New("supervisor returned wrong target") - } - return &units[0], nil -} - -func contains(values []string, value string) bool { - for _, v := range values { - if v == value { - return true - } - } - return false -} - -func inside(parent, path string) bool { - relative, err := filepath.Rel(parent, path) - return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) -} - -func (c *Controller) validateProfile(u *container, owner, workspace string) error { - h := u.HostConfig - uid, err := strconv.Atoi(strings.Split(u.Config.User, ":")[0]) - if err != nil || uid <= 0 || u.Config.Labels[BindingLabel] != owner || u.State.Paused || u.State.Restarting || - h.Privileged || h.PidMode != "" || h.IpcMode != "private" || h.CgroupnsMode != "private" || - h.NetworkMode != "none" || len(h.CapAdd) != 0 || !contains(h.CapDrop, "ALL") || - (len(h.SecurityOpt) != 1 || !contains(h.SecurityOpt, "no-new-privileges")) || len(h.Devices)+len(h.DeviceRequests)+len(h.DeviceCgroupRules)+len(h.VolumesFrom) != 0 || - h.RestartPolicy.Name != "no" { - return errors.New("placement is outside qualified unprivileged local Docker profile") - } - if !filepath.IsAbs(workspace) || filepath.Clean(workspace) != workspace { - return errors.New("workspace must be canonical and absolute") - } - resolved, err := filepath.EvalSymlinks(workspace) - if err != nil || resolved != workspace { - return errors.New("workspace must exist without symlink aliases") - } - info, err := os.Stat(workspace) - if err != nil || !info.IsDir() { - return errors.New("workspace must be a directory") - } - var fs syscall.Statfs_t - if err := syscall.Statfs(workspace, &fs); err != nil { - return err - } - switch uint64(fs.Type) { - case 0xef53, 0x58465342, 0x9123683e, 0x01021994: - default: - return errors.New("unqualified workspace filesystem") - } - socket, err := filepath.EvalSymlinks(c.socketPath) - if err != nil { - return fmt.Errorf("cannot resolve supervisor socket: %w", err) - } - mounts, err := c.hostMounts(workspace) - if err != nil { - return err - } - found := false - for _, m := range u.Mounts { - canonical, err := filepath.EvalSymlinks(m.Source) - if err != nil || canonical != m.Source || inside(m.Source, c.root) || inside(c.root, m.Source) || inside(m.Source, socket) { - return errors.New("mount aliases or exposes controller state or supervisor socket") - } - if err := unaliasedSource(m.Source, mounts); err != nil { - return err - } - if m.Type != "bind" || (m.Propagation != "rprivate" && m.Propagation != "") { - return errors.New("only private bind mounts are qualified") - } - if m.Source == workspace && m.RW && !found { - found = true - continue - } - info, err := os.Stat(m.Source) - if err != nil || m.RW || !info.Mode().IsRegular() { - return errors.New("additional mounts must be read-only regular files") - } - } - if !found { - return errors.New("missing exact retained workspace bind") - } - return nil -} diff --git a/apps/daemon/internal/placement/environment.go b/apps/daemon/internal/placement/environment.go deleted file mode 100644 index 1be6113bf..000000000 --- a/apps/daemon/internal/placement/environment.go +++ /dev/null @@ -1,38 +0,0 @@ -package placement - -import ( - "context" - "errors" - "github.com/google/uuid" -) - -func validEnvironment(id string) bool { - parsed, err := uuid.Parse(id) - return err == nil && parsed != uuid.Nil && parsed.String() == id -} - -// Enroll records an unscoped operator placement without Environment authority. -func (c *Controller) Enroll(ctx context.Context, id, owner, workspace string) (*Receipt, error) { - return c.enroll(ctx, id, owner, workspace, "") -} - -// EnrollEnvironment records an immutable operator-confirmed Environment association. -func (c *Controller) EnrollEnvironment(ctx context.Context, id, owner, workspace, environment string) (*Receipt, error) { - if !validEnvironment(environment) { - return nil, errors.New("invalid placement Environment ID") - } - return c.enroll(ctx, id, owner, workspace, environment) -} - -// Retire reconciles only unscoped operator enrollment. -func (c *Controller) Retire(ctx context.Context, id string) (*Receipt, error) { - return c.retirePlacement(ctx, id, "") -} - -// RetireEnvironment requires the same Environment before any supervisor access. -func (c *Controller) RetireEnvironment(ctx context.Context, id, environment string) (*Receipt, error) { - if !validEnvironment(environment) { - return nil, errors.New("invalid placement Environment ID") - } - return c.retirePlacement(ctx, id, environment) -} diff --git a/apps/daemon/internal/placement/environment_linux_test.go b/apps/daemon/internal/placement/environment_linux_test.go deleted file mode 100644 index 4c5998268..000000000 --- a/apps/daemon/internal/placement/environment_linux_test.go +++ /dev/null @@ -1,198 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "os" - "reflect" - "sync" - "testing" -) - -const testEnvironment = "3fb4bdd9-d8c7-4f12-810c-9da932e06bc4" -const otherEnvironment = "384ff427-c83f-4484-80a7-58aa95662f97" - -func (f *fixture) enrollEnvironment() *Receipt { - f.t.Helper() - r, err := f.c.EnrollEnvironment(context.Background(), testID, "owner-1", f.workspace, testEnvironment) - if err != nil { - f.t.Fatal(err) - } - return r -} - -func TestEnvironmentScopeRejectsBeforeSupervisorAccess(t *testing.T) { - for _, state := range []string{"enrolled", "retired"} { - t.Run(state, func(t *testing.T) { - f := newFixture(t) - f.enrollEnvironment() - if state == "retired" { - if _, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err != nil { - t.Fatal(err) - } - } - f.c.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("scope rejection accessed supervisor") - return nil, nil - } - if _, err := f.c.Retire(t.Context(), testID); err == nil { - t.Fatal("scope omitted") - } - for _, id := range []string{otherEnvironment, "", "not-a-uuid", "00000000-0000-0000-0000-000000000000"} { - if _, err := f.c.RetireEnvironment(t.Context(), testID, id); err == nil { - t.Fatal("invalid scope accepted", id) - } - } - }) - } -} - -func TestEnvironmentEnrollmentIsImmutableAndSeparateFromLegacy(t *testing.T) { - f := newFixture(t) - r := f.enrollEnvironment() - if r.Version != 2 || r.EnvironmentID != testEnvironment { - t.Fatal("scope not versioned", r) - } - if _, err := f.c.EnrollEnvironment(t.Context(), testID, "owner-1", f.workspace, otherEnvironment); err == nil { - t.Fatal("scope reassigned") - } - if _, err := f.c.Enroll(t.Context(), testID, "owner-1", f.workspace); err == nil { - t.Fatal("scope downgraded") - } - retained, err := f.c.load(testID) - if err != nil || !reflect.DeepEqual(r, retained) { - t.Fatal("enrollment changed", err) - } - legacy := newFixture(t) - old := legacy.enroll() - if old.Version != 1 || old.EnvironmentID != "" { - t.Fatal("legacy enrollment changed") - } - if _, err := legacy.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil { - t.Fatal("legacy record gained scope") - } - if legacy.stops+legacy.removals != 0 { - t.Fatal("legacy target mutated") - } - if _, err := legacy.c.Retire(t.Context(), testID); err != nil { - t.Fatal(err) - } -} - -func TestEnvironmentReceiptVersionRejectsMissingOrDowngradedScope(t *testing.T) { - for _, mode := range []string{"missing", "malformed", "downgraded", "future"} { - t.Run(mode, func(t *testing.T) { - f := newFixture(t) - r := f.enrollEnvironment() - switch mode { - case "missing": - r.EnvironmentID = "" - case "malformed": - r.EnvironmentID = "invalid" - case "downgraded": - r.Version = 1 - case "future": - r.Version = 3 - } - data, err := json.Marshal(r) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(f.c.recordPath(testID), data, 0600); err != nil { - t.Fatal(err) - } - f.c.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("invalid receipt accessed supervisor") - return nil, nil - } - if _, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil { - t.Fatal("invalid scoped receipt accepted") - } - if _, err := f.c.Retire(t.Context(), testID); err == nil { - t.Fatal("invalid scoped receipt accepted by legacy path") - } - }) - } -} - -func TestEnvironmentRetirementConcurrentRecovery(t *testing.T) { - f := newFixture(t) - f.enrollEnvironment() - var wg sync.WaitGroup - results := make(chan *Receipt, 4) - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - fresh := *f.c - r, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment) - if err != nil { - t.Error(err) - return - } - results <- r - }() - } - wg.Wait() - close(results) - var expected *Receipt - for r := range results { - if expected == nil { - expected = r - } - if !reflect.DeepEqual(expected, r) { - t.Fatal("concurrent receipt changed") - } - } - if expected == nil || expected.State != "retired" || expected.EnvironmentID != testEnvironment || f.stops != 1 || f.removals != 1 { - t.Fatal("retirement not unique", expected) - } - fresh := *f.c - fresh.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("recovered receipt touched supervisor") - return nil, nil - } - again, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment) - if err != nil || !reflect.DeepEqual(expected, again) { - t.Fatal("fresh recovery differs", err) - } -} - -func TestEnvironmentUnknownAndChangedTargetRemainUnresolved(t *testing.T) { - for _, failure := range []string{"lost-remove-ack", "restart"} { - t.Run(failure, func(t *testing.T) { - f := newFixture(t) - f.enrollEnvironment() - f.fail = failure - if failure == "restart" { - f.unit.State.StartedAt = "replacement" - } - if r, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil || r.State == "retired" { - t.Fatal("uncertainty lost", r, err) - } - fresh := *f.c - if r, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil || r.State == "retired" { - t.Fatal("retry fabricated retirement", r, err) - } - if _, err := fresh.Retire(t.Context(), testID); err == nil { - t.Fatal("unscoped retry bypassed unknown scope") - } - }) - } -} - -func TestInvalidEnvironmentEnrollmentDoesNotTouchSupervisor(t *testing.T) { - f := newFixture(t) - f.c.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("invalid scope touched supervisor") - return nil, errors.New("unexpected") - } - for _, id := range []string{"", "3FB4BDD9-D8C7-4F12-810C-9DA932E06BC4", "00000000-0000-0000-0000-000000000000"} { - if _, err := f.c.EnrollEnvironment(t.Context(), testID, "owner-1", f.workspace, id); err == nil { - t.Fatal("invalid scope enrolled") - } - } -} diff --git a/apps/daemon/internal/placement/mounts_linux.go b/apps/daemon/internal/placement/mounts_linux.go deleted file mode 100644 index bb32822bf..000000000 --- a/apps/daemon/internal/placement/mounts_linux.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build linux - -package placement - -import ( - "errors" - "os" - "path/filepath" - "strings" -) - -type hostMount struct { - device, root, point string -} - -// The initial profile excludes host mount aliases rather than trying to resolve -// arbitrary backing-path graphs. Mount administration remains a trusted host act. -func (c *Controller) hostMounts(workspace string) ([]hostMount, error) { - data, err := os.ReadFile(filepath.Join(c.procRoot, "self/mountinfo")) - if err != nil { - return nil, err - } - decode := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`) - var mounts []hostMount - for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { - fields := strings.Fields(line) - if len(fields) < 10 { - return nil, errors.New("incomplete host mount evidence") - } - mount := hostMount{device: fields[2], root: decode.Replace(fields[3]), point: decode.Replace(fields[4])} - if !filepath.IsAbs(mount.point) || filepath.Clean(mount.point) != mount.point { - return nil, errors.New("invalid host mount evidence") - } - if mount.point != workspace && inside(workspace, mount.point) { - return nil, errors.New("nested workspace mounts are unqualified") - } - mounts = append(mounts, mount) - } - return mounts, nil -} - -func unaliasedSource(source string, mounts []hostMount) error { - selected := -1 - for i, mount := range mounts { - if !inside(mount.point, source) { - continue - } - if selected == -1 || len(mount.point) > len(mounts[selected].point) { - selected = i - } - } - if selected == -1 || mounts[selected].root != "/" { - return errors.New("mount source lacks whole-filesystem evidence; host aliases and subvolume roots are unqualified") - } - count := 0 - for _, mount := range mounts { - if mount.device == mounts[selected].device { - count++ - } - // A stacked mount point is ambiguous even if it uses another device. - if mount.point == mounts[selected].point && mount.device != mounts[selected].device { - return errors.New("stacked source mounts are unqualified") - } - } - if count != 1 { - return errors.New("multiple host mounts of the source filesystem are unqualified") - } - return nil -} diff --git a/apps/daemon/internal/placement/mounts_linux_test.go b/apps/daemon/internal/placement/mounts_linux_test.go deleted file mode 100644 index f50089fdb..000000000 --- a/apps/daemon/internal/placement/mounts_linux_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "fmt" - "path/filepath" - "testing" -) - -func TestAmbiguousHostMountsCannotAuthorizeEnrollment(t *testing.T) { - for _, scenario := range []string{"directory-bind", "whole-filesystem-bind", "nested-mount", "stacked-mount", "missing-evidence"} { - t.Run(scenario, func(t *testing.T) { - f := newFixture(t) - info := "1 0 8:2 / / rw - ext4 /dev/test rw\n" - switch scenario { - case "directory-bind": - info += fmt.Sprintf("2 1 8:2 /home/operator %s rw - ext4 /dev/test rw\n", f.workspace) - case "whole-filesystem-bind": - info += fmt.Sprintf("2 1 8:2 / %s rw - ext4 /dev/test rw\n", f.workspace) - case "nested-mount": - info += fmt.Sprintf("2 1 0:8 / %s/secret rw - tmpfs tmpfs rw\n", f.workspace) - case "stacked-mount": - info += fmt.Sprintf("2 1 0:8 / %s rw - tmpfs tmpfs rw\n3 1 0:9 / %s rw - tmpfs tmpfs rw\n", f.workspace, f.workspace) - case "missing-evidence": - info = "" - } - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), info) - if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil { - t.Fatal("ambiguous host mount authorized") - } - if f.stops+f.removals != 0 { - t.Fatal("unqualified supervisor mutated") - } - }) - } -} - -func TestNewHostAliasIsRejectedBeforeRetirement(t *testing.T) { - f := newFixture(t) - f.enroll() - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n2 1 8:2 /home/operator /mnt/alias rw - ext4 /dev/test rw\n") - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("changed host mount topology accepted") - } - if f.stops+f.removals != 0 { - t.Fatal("supervisor mutated before mount qualification") - } -} - -func TestUnrelatedNamespaceMountDoesNotInvalidateStorageEvidence(t *testing.T) { - f := newFixture(t) - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n2 1 0:4 net:[12345] /run/docker/netns/example rw - nsfs nsfs rw\n") - f.enroll() -} diff --git a/apps/daemon/internal/placement/observe_linux.go b/apps/daemon/internal/placement/observe_linux.go deleted file mode 100644 index 90401a26f..000000000 --- a/apps/daemon/internal/placement/observe_linux.go +++ /dev/null @@ -1,123 +0,0 @@ -//go:build linux - -package placement - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" -) - -func (c *Controller) process(pid int) (Process, bool, error) { - if pid <= 0 { - return Process{}, false, errors.New("missing process identity") - } - data, err := os.ReadFile(filepath.Join(c.procRoot, strconv.Itoa(pid), "stat")) - if err != nil { - return Process{}, false, err - } - end := strings.LastIndexByte(string(data), ')') - if end < 0 { - return Process{}, false, errors.New("malformed process identity") - } - fields := strings.Fields(string(data[end+1:])) - if len(fields) < 20 { - return Process{}, false, errors.New("incomplete process identity") - } - return Process{PID: pid, Start: fields[19]}, fields[0] != "Z" && fields[0] != "X", nil -} - -func (c *Controller) group(pid int, id string) (string, error) { - data, err := os.ReadFile(filepath.Join(c.procRoot, strconv.Itoa(pid), "cgroup")) - if err != nil { - return "", err - } - for _, line := range strings.Split(string(data), "\n") { - if !strings.HasPrefix(line, "0::/") { - continue - } - path := strings.TrimPrefix(line, "0::") - base := filepath.Base(path) - if filepath.Clean(path) != path || (base != id && base != "docker-"+id+".scope") { - return "", errors.New("cgroup does not identify the exact container") - } - return path, nil - } - return "", errors.New("placement requires cgroup v2") -} - -func (c *Controller) groupPath(group string) (string, error) { - if !filepath.IsAbs(group) || filepath.Clean(group) != group || group == "/" { - return "", errors.New("invalid saved cgroup") - } - return filepath.Join(c.cgroupRoot, group), nil -} - -func (c *Controller) members(group string) ([]Process, error) { - path, err := c.groupPath(group) - if err != nil { - return nil, err - } - var members []Process - err = filepath.WalkDir(path, func(p string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Name() != "cgroup.procs" { - return nil - } - data, err := os.ReadFile(p) - if err != nil { - return err - } - for _, value := range strings.Fields(string(data)) { - pid, err := strconv.Atoi(value) - if err != nil { - return err - } - identity, _, err := c.process(pid) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return err - } - members = append(members, identity) - } - return nil - }) - return members, err -} - -func (c *Controller) observeRetired(r *Receipt) error { - path, err := c.groupPath(r.Target.Cgroup) - if err != nil { - return err - } - data, err := os.ReadFile(filepath.Join(path, "cgroup.events")) - if errors.Is(err, os.ErrNotExist) { - if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { - return errors.New("cgroup evidence unavailable") - } - } else if err != nil { - return err - } else if !strings.Contains("\n"+string(data), "\npopulated 0\n") { - return errors.New("placement cgroup remains populated") - } - for _, old := range append(append([]Process{}, r.Members...), r.Target.Init) { - current, live, err := c.process(old.PID) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return err - } - if current == old && live { - return fmt.Errorf("old placement process %d remains live", old.PID) - } - } - return nil -} diff --git a/apps/daemon/internal/placement/state_linux.go b/apps/daemon/internal/placement/state_linux.go deleted file mode 100644 index 31b72aa9a..000000000 --- a/apps/daemon/internal/placement/state_linux.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "syscall" - "time" -) - -func (c *Controller) recordPath(id string) string { return filepath.Join(c.root, id+".json") } - -func privateFile(f *os.File) error { - info, err := f.Stat() - if err != nil { - return err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { - return errors.New("placement state must be a private, owned regular file") - } - return nil -} - -func secureDirectory(path string) error { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return errors.New("state directory must be canonical and absolute") - } - if path != "/" { - if err := secureDirectory(filepath.Dir(path)); err != nil { - return err - } - } - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - if err := os.Mkdir(path, 0700); err != nil && !errors.Is(err, os.ErrExist) { - return err - } - parent, syncErr := os.Open(filepath.Dir(path)) - if syncErr != nil { - return syncErr - } - syncErr = parent.Sync() - closeErr := parent.Close() - if syncErr != nil { - return syncErr - } - if closeErr != nil { - return closeErr - } - info, err = os.Lstat(path) - } - if err != nil { - return err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.IsDir() || info.Mode().Perm()&0022 != 0 || (st.Uid != 0 && st.Uid != uint32(os.Getuid())) { - return fmt.Errorf("untrusted placement state directory: %s", path) - } - return nil -} - -func (c *Controller) lock(ctx context.Context, id string) (func(), error) { - if !fullID.MatchString(id) { - return nil, errors.New("require full immutable container ID") - } - if err := secureDirectory(c.root); err != nil { - return nil, err - } - info, err := os.Stat(c.root) - if err != nil || info.Mode().Perm() != 0700 { - return nil, errors.New("placement state directory must have mode 0700") - } - f, err := os.OpenFile(filepath.Join(c.root, id+".lock"), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600) - if err != nil { - return nil, err - } - if err := privateFile(f); err != nil { - f.Close() - return nil, err - } - for { - err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err == nil { - break - } - if err != syscall.EWOULDBLOCK { - f.Close() - return nil, err - } - select { - case <-ctx.Done(): - f.Close() - return nil, ctx.Err() - case <-time.After(20 * time.Millisecond): - } - } - return func() { _ = f.Close() }, nil -} - -func (c *Controller) load(id string) (*Receipt, error) { - f, err := os.OpenFile(c.recordPath(id), os.O_RDONLY|syscall.O_NOFOLLOW, 0) - if err != nil { - return nil, err - } - defer f.Close() - if err := privateFile(f); err != nil { - return nil, err - } - var r Receipt - if err := json.NewDecoder(f).Decode(&r); err != nil { - return nil, err - } - validScope := (r.Version == 1 && r.EnvironmentID == "") || (r.Version == 2 && validEnvironment(r.EnvironmentID)) - if !validScope || r.Target.Container != id || !ownerID.MatchString(r.Owner) || - (r.State != "enrolled" && r.State != "stopping" && r.State != "retired") || - (r.State == "retired") != (r.RetiredAt != nil) { - return nil, errors.New("invalid placement receipt") - } - return &r, nil -} - -func (c *Controller) save(r *Receipt) error { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err - } - f, err := os.CreateTemp(c.root, ".receipt-*") - if err != nil { - return err - } - defer os.Remove(f.Name()) - if _, err = f.Write(append(data, '\n')); err == nil { - err = f.Sync() - } - closeErr := f.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - if err := os.Rename(f.Name(), c.recordPath(r.Target.Container)); err != nil { - return err - } - return c.syncDir(c.root) -} - -func syncDirectory(path string) error { - dir, err := os.Open(path) - if err != nil { - return err - } - defer dir.Close() - return dir.Sync() -} diff --git a/apps/daemon/internal/placement/types.go b/apps/daemon/internal/placement/types.go deleted file mode 100644 index 23079dca4..000000000 --- a/apps/daemon/internal/placement/types.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package placement owns explicit operator-managed execution placement retirement. -// It does not authorize Core dispatch, release harnesses or replay native work. -package placement - -import "time" - -// BindingLabel marks a container explicitly created for operator enrollment. -const BindingLabel = "io.oac.placement" - -// Receipt is durable local evidence for one exact placement incarnation. -// Only State == "retired" reports qualified settlement; other states are unknown. -type Receipt struct { - EnvironmentID string `json:"environment_id,omitempty"` - Version int `json:"version"` - State string `json:"state"` - Owner string `json:"owner"` - Target Target `json:"target"` - Members []Process `json:"members"` - RequestedAt time.Time `json:"requested_at"` - RetiredAt *time.Time `json:"retired_at,omitempty"` -} - -// Target binds local supervisor, immutable container and observed incarnation. -type Target struct { - HostBootID string `json:"host_boot_id"` - Supervisor string `json:"supervisor"` - Container string `json:"container"` - Created string `json:"created"` - Started string `json:"started"` - Restarts int `json:"restarts"` - Image string `json:"image"` - Workspace string `json:"workspace"` - Cgroup string `json:"cgroup"` - Init Process `json:"init"` -} - -// Process includes Linux start ticks to distinguish a reused PID. -type Process struct { - PID int `json:"pid"` - Start string `json:"start"` -} diff --git a/apps/daemon/internal/transport/event_flow_test.go b/apps/daemon/internal/transport/event_flow_test.go index 4d7cf10ff..abc2e631a 100644 --- a/apps/daemon/internal/transport/event_flow_test.go +++ b/apps/daemon/internal/transport/event_flow_test.go @@ -178,3 +178,23 @@ func TestOversizedExecutionEventClosesConnection(t *testing.T) { t.Fatalf("connection failure: %v", c.Err()) } } + +// Reasoning is the upstream replacement for thinking; it must consume the same +// negotiated window without starving control receipts on a saturated Run. +func TestReasoningCreditPreservesControlReceipt(t *testing.T) { + c := flowConn(t, "run") + event := proto.Envelope{Type: proto.TypeReasoning, ID: "run"} + for range proto.ExecutionEventWindow { + if err := reserveCredit(c, t.Context(), event); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + if err := reserveCredit(c, ctx, event); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("reasoning bypassed credit: %v", err) + } + if err := reserveCredit(c, t.Context(), proto.Envelope{Type: proto.TypeInteractionDecisionAck, ID: "run"}); err != nil { + t.Fatal(err) + } +} diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go index 8ab3c4604..3276a9c17 100644 --- a/apps/daemon/internal/transport/ws.go +++ b/apps/daemon/internal/transport/ws.go @@ -464,14 +464,13 @@ func isPermanentClose(err error) bool { return websocket.IsCloseError(err, CloseRuntimeDeleted) } -// withQueryParams appends non-secret parameters and removes legacy credentials. +// withQueryParams appends non-secret parameters. func withQueryParams(raw string, params map[string]string) (string, error) { u, err := url.Parse(raw) if err != nil { return "", fmt.Errorf("transport: invalid ws url") } q := u.Query() - q.Del("token") for k, v := range params { q.Set(k, v) } diff --git a/apps/daemon/internal/transport/ws_test.go b/apps/daemon/internal/transport/ws_test.go index 3e4d8a7d4..12e4859dd 100644 --- a/apps/daemon/internal/transport/ws_test.go +++ b/apps/daemon/internal/transport/ws_test.go @@ -98,7 +98,7 @@ func TestDialPassesAuthOnlyInHeader(t *testing.T) { headers := http.Header{"Authorization": {"Bearer obsolete"}, "X-Test": {"preserved"}} conn, err := transport.Dial(context.Background(), transport.DialOptions{ - WSURL: httpToWS(srv.URL) + "/agent-daemon/ws?token=legacy", + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", HTTPHeader: headers, DeviceID: "rt_abc", Credential: "shh-secret", diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 0a08375ae..5f25d52af 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -126,9 +126,6 @@ func (s *session) Cancel(context.Context) error { func (s *session) CancellationOutcome() proto.DonePayload { return proto.DonePayload{Content: "cancelled", Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}} } -func (s *session) Steer(ctx context.Context, p proto.PromptSteerPayload) error { - return s.SteerWithReceipt(ctx, p, func() {}) -} func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload, written func()) error { text, err := p.Input.TextOnly() if err != nil { @@ -162,7 +159,7 @@ func run() error { registry := agent.NewRegistry() 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, + Streaming: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, })}, harnessconfig.Configuration{}) registry.RegisterExecutor("fixture_harness", h.prepare) sink := &sender{encoder: json.NewEncoder(os.Stdout)} diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 5966de38f..4ce53087f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -366,10 +366,10 @@ 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. 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 Settings → Models and credentials or the Overview. +- **Add node**: the Limits choice first (Recommended, which leaves the limits for Core to derive from the host once it enrolls, or Custom with the two numbers), 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 Settings → Models and credentials 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 a question-mark help tip 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. +- **Edit node**: the name, then the Limits choice, Recommended showing the node's `recommended_capacity` while Core has one, or Custom with the numbers. 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. ### Navigation @@ -434,7 +434,7 @@ Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Workspace storage shows the selected configuration's adapter and tenant capacity (in bytes units, or No tenant capacity limit) under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. The configuration ID and parameters JSON sit in a closed Configuration details disclosure at the end of the card. Select new configuration accepts an adapter identifier, configuration ID, parameters JSON object and optional write-only credential JSON object. Edit tenant capacity keeps the current configuration identity. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. Never prefill credentials or persist their drafts across navigation. A failed read is not an unconfigured filesystem, and an uncertain write is not a safe automatic retry. -Models and credentials stacks one default-model card per Harness. Its header carries the Harness name, its readiness (Ready in green; Model provider not set in amber, with Set as the primary button; Disabled in neutral, with a View startup settings text action), a Default pill, a help tip and the Set, Replace and Clear actions; rows hold model ID, provider protocol, base URL, key presence, token limits when set and update time, and a foot holds Usage details. Set and Replace open one form dialog. The adapter declaration supplies supported protocols, native JSON support and required limits without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately. +Models and credentials stacks one default-model card per Harness. Its header carries the Harness name, its readiness (Ready in green; Model provider not set in amber, with Set as the primary button; Disabled in neutral, with a View startup settings text action), a Default pill, a help tip and the Set, Replace and Clear actions; rows hold model ID, provider protocol, base URL, key presence, token limits when set and update time, and a foot holds Usage details. Set and Replace open one form dialog. The adapter declaration supplies supported protocols and native JSON support without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately. Service and deployment shows installation addresses, an Installation details disclosure, closed by default, with the installation ID and source commit, existing Domain and HTTPS actions, and the startup settings Core loaded. Process settings remain read-only here; their source stays the installation configuration. Each row's label is the setting's environment variable in code style (OAC_LOG_LEVEL), as Core reports it, wrapping after an underscore so a phone keeps all three columns; values show as is, with arrays joined, On or Off for booleans and None for an empty list. How to change a setting in the section header links to the configuration guide. Sensitive settings show only Configured or Not set. Default uses a neutral pill. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 4c14c1539..e7320e85a 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -31,11 +31,11 @@ The console runs beside the administrator's own Core, with execution, files and - **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, where Core opens Core metrics, a node opens its page on Nodes and E2B opens Sandbox metrics, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (whether Core can run Turns: execution slots, the Turn queue, its p95 wait and interrupted Turns, connected daemons, database latency, the Core process's CPU and memory, and background jobs; Go heap, goroutines and the database pool sit in a collapsed Diagnostics area), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (hosted Runtimes across projects over time, active against total; a sandbox opens in a dialog with its figures and CPU and memory charts; an E2B deployment adds what Core holds in E2B's cloud), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), 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). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's 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. An empty list says, in one sentence, that applications create these resources through the Agents API, and links to that resource's section of the API guide; Agent metrics does the same for Sessions. -- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each Harness's default model and shows whether each Harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. -- 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. +- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes offers Recommended or Custom limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each Harness's default model and shows whether each Harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. +- 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, 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 Settings → Sandbox and scheduling. 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. Changes keep the saved Runtime. When it differs from the Runtime this console serves, Sandbox and scheduling adds a Runtime row with both source commits and Use this installation's Runtime, which opens Change resources on its review with that Runtime; a console without a Runtime manifest leaves the release to Advanced settings. 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. +- **Online sandbox configuration** changes the same backend's resources 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 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. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. - **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. - **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. diff --git a/apps/web/README.md b/apps/web/README.md index d81b9c0ac..4f1b69efa 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -11,7 +11,7 @@ ## Run the console locally -Set up the checkout as described in the [development guide](../../docs/development.md), then start the fixture console and the development server in separate terminals from the repository root: +Set up the checkout as described in [Develop OpenAgentCore](../../docs/development.md), then start the fixture console and the development server in separate terminals from the repository root: ```sh node apps/web/e2e/fixture-console.mjs @@ -40,7 +40,7 @@ pnpm test:web:acceptance ## README screenshots -The fixture has an opt-in scene for Overview and Agent metrics, including five available nodes. From the repository root, start these in separate terminals: +The fixture console has an opt-in demo scene that fills Overview and Agent metrics with five available nodes. From the repository root, start these in separate terminals, then sign in at `http://127.0.0.1:4394` with the fixture key `fixture-core-key-3f9a2c71`: ```sh OAC_WEB_SCREENSHOT_DEMO=1 AGENTS_FIXTURE_PORT=18394 node apps/web/e2e/fixture-console.mjs @@ -50,4 +50,4 @@ OAC_WEB_SCREENSHOT_DEMO=1 AGENTS_FIXTURE_PORT=18394 node apps/web/e2e/fixture-co OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:18394 pnpm --filter @oac/web exec vite --host 127.0.0.1 --mode test --port 4394 ``` -Open `http://127.0.0.1:4394` in Chrome and sign in with the fixture key `fixture-core-key-3f9a2c71`. Capture Overview and Agent metrics in light mode, once in English and once in Chinese using the console language menu. Check that all five nodes load and metrics have no partial-data warning before capturing. For a remote preview, forward port 4394 over SSH and capture in local Chrome. Keep the original resolution, crop browser chrome and add a plain macOS-style window bar. Save the four images as `docs/assets/console-*.webp`; the READMEs link them and the distribution manifest includes them. Normal acceptance data and production builds do not enable this scene. +The `docs/assets/console-*.webp` images are captured from this scene in light mode, once in English and once in Chinese. Acceptance runs and production builds never enable the scene. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 2735ebc8c..fbe39052b 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -55,7 +55,7 @@ export async function failNext(request: APIRequestContext, failure: { method: st } /** Registers a node, or changes one, as a host running the last enrollment command (or, with its `enrollment_id`, another one) would. */ -export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string; enrollment_id?: string; rollout?: SandboxNodeRollout }) { +export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; enrollment_id?: string; rollout?: SandboxNodeRollout }) { await request.post(`${fixture}/__fixture/node`, { data: node }); } diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index 124a7966c..535af7a62 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -84,9 +84,9 @@ 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: "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() }, + { 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, recommended_capacity: { max_active: 6, max_retained: 6 }, 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: "host_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, 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, recommended_capacity: null, 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, recommended_capacity: null, 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"); const allocations = hosted.map((session, index) => { diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index e662bc01a..1f2513802 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -22,10 +22,9 @@ const LOCKOUT_AFTER = 3; const LOCKOUT_SECONDS = 30; let state; -const hex = (c) => c.repeat(64); -/** The Runtime release this fixture's distribution manifest describes. */ -const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${hex("1")}`, image_manifest_digest: `sha256:${hex("2")}`, microsandbox_ref: `oac-runtime@sha256:${hex("3")}`, runtime_sha256: hex("4"), firmware_sha256: hex("5") }; -const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} }; +const SOURCE_COMMIT = "c0ffee".padEnd(40, "0"); +// microsandbox runs every sandbox on workspace storage, so the installation has it selected. +const WORKSPACE_STORAGE = { id: "5d0c2a4e-8f6b-4c1d-9e3a-7b2f1a0c6d48", adapter: "nfs", parameters: { root: "/srv/oac-workspaces", namespace_id: "22222222-2222-4222-8222-222222222222", uid: 65532 }, tenant_capacity_mib: 0 }; /** * config.json's public_url. "public": an HTTPS address, so applications get an API base URL; @@ -50,7 +49,7 @@ function installation() { return { // As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url. object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, - local_only: local, source_commit: release.source_commit, + local_only: local, source_commit: SOURCE_COMMIT, configuration: { settings: [ // The settings Core reports (services/core/internal/processconfig), with the enabled harnesses of this fixture. @@ -77,7 +76,7 @@ function installation() { const noNodeRollout = () => ({ state: "settled", previous_generation_sandboxes: 0, nodes: null }); function nodeRollout(previous = 0) { - const nodes = { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 }; + const nodes = { ready: 0, preparing: 0, failed: 0, unknown: 0 }; for (const node of state.nodes) nodes[node.rollout.state]++; return { state: nodes.preparing > 0 ? "preparing" : "settled", previous_generation_sandboxes: previous, nodes }; } @@ -87,7 +86,7 @@ function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { } function configuredDeployment() { - return { installation_id: INSTALLATION_ID, provider: "docker", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "docker", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 } }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; @@ -262,6 +261,7 @@ async function adminWrite(request, response, path) { function adminRead(response, path, url) { const a = state.admin; if (path === "/installation") return send(response, 200, installation()); + if (path === "/workspace-storage") return send(response, 200, WORKSPACE_STORAGE); if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); if (path === "/request-logs") return send(response, 200, requestLog(url)); @@ -397,7 +397,7 @@ async function sandboxRoute(request, response, path, url) { if (initialize && state.deployment.provider) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); if (!initialize && !state.deployment.provider) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); const e2b = input.provider === "e2b"; - if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + if (!e2b && !input.resources) return error(response, 400, "resources are required.", "invalid_sandbox_configuration"); // As Core (ErrSandboxPublicURLUnreachable): E2B sandboxes reach Core over the internet, which a loopback public_url cannot serve. if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, OAC_PUBLIC_URL for Core).", "sandbox_configuration_error"); // Synthetic classifier outcomes only; never persist or echo submitted keys. @@ -409,7 +409,7 @@ async function sandboxRoute(request, response, path, url) { // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; const previous = state.deployment; - const specification = { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }; + const specification = { resources }; const explicitKey = e2b && Object.hasOwn(input, "credential"); const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.configuration.template === previous.configuration?.template); // Omission can be a no-op; every explicit key, including identical bytes, @@ -516,12 +516,11 @@ async function executorCredentialRoute(request, response, projectId, environment /** * A node as it first registers with the last enrollment command: not yet connected, - * provider not ready, with the token's limits and enrollment ID. Like Core, it has no - * diagnostic field until one is reported. + * provider not ready, with the token's limits and enrollment ID. */ function registeredNode(nodeId) { const { enrollment_id = null, ...limits } = state.enrollment ?? { max_active: 1, max_retained: 1 }; - return { rollout: { state: "unknown", ready_generation: null }, id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; + return { rollout: { state: "unknown", ready_generation: null }, id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, recommended_capacity: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-configuration$/; @@ -540,8 +539,7 @@ function providerProblem(harness, input) { if (!httpsBase(input.base_url)) return "model provider requires an HTTPS base_url without credentials, query or fragment"; if (!["anthropic", "responses", "chat_completions"].includes(input.protocol)) return "unsupported model provider protocol"; if (!input.api_key.trim() || Buffer.byteLength(input.api_key) > 16384 || /[\0\r\n]/.test(input.api_key)) return "invalid model provider API key"; - if ((input.max_output_tokens ?? 0) > (input.context_window ?? 0)) return "invalid model token limits"; - if (harness === "mcode" && !(input.context_window > 0 && input.max_output_tokens > 0)) return "selected harness requires positive model context_window and max_output_tokens"; + if (input.context_window && (input.max_output_tokens ?? 0) > input.context_window) return "invalid model token limits"; return null; } @@ -550,11 +548,11 @@ function providerProblem(harness, input) { * (services/core/internal/api/harness_model_providers.go): the list * reflects every write; an unknown harness or an unset provider is 404; PUT * takes a JSON object of at most 32 KiB, is a full replacement that needs the - * key every time (mcode also both limits) and answers with the safe view; + * key every time and answers with the safe view; * DELETE is 204 and safe to repeat. A disabled harness may still be configured. */ async function harnessRoute(request, response, path) { - const support = (id) => ({ protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id !== "mcode", token_limits_required: id === "mcode" }); + const support = (id) => ({ protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id === "claude_sdk", accepts_max_output_tokens: id !== "codex" }); const view = (id) => ({ model_configuration_support: support(id), object: "core.harness", id, enabled: state.harnesses[id].enabled, default: state.harnesses[id].default, model_configuration: state.harnesses[id].provider }); if (path === "/harnesses" && request.method === "GET") return send(response, 200, { object: "list", data: Object.keys(state.harnesses).map(view) }); const match = path.match(HARNESS_PROVIDER); @@ -624,21 +622,20 @@ async function fixtureRoute(request, response, url) { return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/node" && request.method === "POST") { - // { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, diagnostic, - // or another command's enrollment_id); an empty diagnostic removes the field, as Core omits it. + // { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, + // or another command's enrollment_id). const { id: nodeId, ...fields } = await body(request); let node = state.nodes.find((entry) => entry.id === nodeId); if (!node) state.nodes.push(node = registeredNode(nodeId)); Object.assign(node, fields); // A control may supply an explicit target observation. Existing enrollment // cases only supply connection facts, so give that newly joined target a pin. - if (!fields.rollout && ("online" in fields || "provider_ready" in fields || "diagnostic" in fields)) { + if (!fields.rollout && ("online" in fields || "provider_ready" in fields)) { node.rollout = !node.online ? { state: "unknown", ready_generation: node.rollout.ready_generation } : node.provider_ready ? { state: "ready", ready_generation: state.deployment.generation } - : { state: "failed", ready_generation: node.rollout.ready_generation, diagnostic: node.diagnostic || "provider_unavailable" }; + : { state: "failed", ready_generation: node.rollout.ready_generation, diagnostic: "provider_unavailable" }; } if (state.deployment.mode === "nodes") state.deployment.rollout = nodeRollout(state.deployment.rollout.previous_generation_sandboxes); - if (!node.diagnostic) delete node.diagnostic; return send(response, 200, node); } if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes }); @@ -649,8 +646,6 @@ http.createServer(async (request, response) => { const url = new URL(request.url, `http://127.0.0.1:${port}`); try { if (url.pathname.startsWith("/__fixture/")) return await fixtureRoute(request, response, url); - // The console service serves its distribution manifest to anyone, as nodes download it. - if (url.pathname === "/node-install/manifest.json") return send(response, 200, manifest); if (request.headers.authorization) state.violations.push(`Authorization header on ${request.method} ${url.pathname}`); if (url.pathname.startsWith("/console/")) return await consoleRoute(request, response, url); const signedIn = state.auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 51c22b49b..5fb60fca2 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -24,6 +24,7 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same // 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); + await add.getByRole("radio", { name: "Custom" }).click(); await add.getByLabel("Sandboxes at once").fill("3"); const tokenRequest = () => page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); const issued = tokenRequest(); @@ -91,17 +92,27 @@ 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 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"); + // Connected but not ready, it keeps waiting for its provider; once ready, the node is connected. + await setNode(request, { id: "node-new", online: true }); + await expect(problem).toContainText("Sandbox provider unavailable"); await expect(progress).toContainText("Waiting for Docker"); - await setNode(request, { id: "node-new", provider_ready: true, diagnostic: "" }); + await setNode(request, { id: "node-new", provider_ready: true }); await expect(progress).toHaveText("edge-04 · Connected"); await add.getByRole("button", { name: "Done" }).click(); await expect(add).toBeHidden(); - // A finished flow leaves no limits behind: the next node starts from the defaults. + // A finished flow leaves no limits behind: the next node starts from the recommended ones. await page.getByRole("button", { name: "Add node" }).click(); - await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2"); + await expect(add.getByRole("radio", { name: "Recommended" })).toBeChecked(); + await expect(add.getByLabel("Sandboxes at once")).toHaveCount(0); +}); + +test("asks Core to recommend a new node's limits unless the administrator enters them", async ({ page, request }) => { + await openConsole(page, request, "nodes"); + await page.getByRole("button", { name: "Add node" }).click(); + const add = page.getByRole("dialog", { name: "Add node" }); + const issued = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); + await add.getByRole("button", { name: "Generate command" }).click(); + expect((await issued).postDataJSON()).toEqual({}); }); test("issues no command before the installation is read, for a loopback public URL or without node files, and sees a fix on reopening", async ({ page, request }) => { @@ -192,7 +203,7 @@ test("gives the host's uninstall command even when the installation must be read await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'https:\/\/core\.example\.com\/node-install\/node-install\.pyz'/); }); -test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { +test("sets up own-machine sandboxes page by page", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); await page.getByRole("button", { name: "Own machines" }).click(); @@ -201,8 +212,8 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); await page.getByRole("button", { name: "Save configuration" }).click(); - // Own machines continue straight to adding the first node, at its limits: no command is issued yet. - await expect(page.getByRole("dialog", { name: "Add node" }).getByLabel("Sandboxes at once")).toBeVisible(); + // Own machines continue straight to adding the first node, with Recommended limits: no command is issued yet. + await expect(page.getByRole("dialog", { name: "Add node" }).getByRole("radio", { name: "Recommended" })).toBeChecked(); const add = page.getByRole("dialog", { name: "Add node" }); await expect(add.getByText("No sudo on this host?")).toHaveCount(0); // None is requested within a second of opening, and Core saw only the deployment write. @@ -211,9 +222,6 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); await add.getByRole("button", { name: "Close dialog" }).click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - // The saved specification carries the Runtime read from the console's manifest. - await page.getByRole("button", { name: "Configuration details", exact: true }).click(); - await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); test("preselects microsandbox and asks once before switching to Docker", async ({ page, request }) => { @@ -305,11 +313,9 @@ test("shows the retained E2B build while a replacement key is checked", async ({ await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled(); }); -test("edits only the saved backend, preserving a custom size and Runtime", async ({ page, request }) => { - const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`, - microsandbox_ref: `oac-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) }; - const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime }; - let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, +test("edits only the saved backend, preserving a custom size", async ({ page, request }) => { + const current = { resources: { cpus: 7, memory_mib: 8192 } }; + let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), suspension: null }; let submitted: Record | null = null; @@ -327,7 +333,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async await expect(page.getByRole("button", { name: "E2B cloud" })).toHaveCount(0); await page.getByRole("button", { name: /^Current/ }).click(); await page.getByRole("button", { name: "Save configuration" }).click(); - await expect.poll(() => submitted).toMatchObject({ provider: "docker", expected_generation: 1, resources: current.resources, runtime }); + await expect.poll(() => submitted).toMatchObject({ provider: "docker", expected_generation: 1, resources: current.resources }); expect(submitted).not.toHaveProperty("core_url"); }); @@ -351,9 +357,6 @@ test("keeps the page usable when Core refuses a sandbox change, and shows a safe const added = page.getByRole("dialog", { name: "Add node" }); await expect(added).toBeVisible(); await added.getByRole("button", { name: "Close dialog" }).click(); - await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Configuration details", exact: true }).click(); - await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => { @@ -365,7 +368,9 @@ test("renames a node and sets how many sandboxes run on it at once", async ({ pa await page.getByRole("row").filter({ hasText: "core-01" }).getByRole("button", { name: "Edit node" }).click(); const edit = page.getByRole("dialog", { name: "Edit node" }); await edit.getByLabel("Name").fill("core-01-large"); - await edit.getByLabel("Sandboxes at once").fill("6"); + await expect(edit).toContainText("Recommended: 6 at once."); + await edit.getByRole("button", { name: "Use recommended" }).click(); + await expect(edit.getByLabel("Sandboxes at once")).toHaveValue("6"); await edit.getByRole("button", { name: "Save" }).click(); await expect(edit).toBeHidden(); await expect(capacity).toContainText("core-01-large"); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index 5f6fd2aef..7f19b121a 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -105,22 +105,6 @@ test("online configuration retains existing resources and stops rapid polling wh expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); }); -test("a saved Runtime moves to the one this installation serves through the reviewed update", async ({ page, request }) => { - await openConsole(page, request, "settings?id=sandbox"); - const served = await (await page.request.get(new URL("/node-install/manifest.json", page.url()).href)).json() as { source_commit: string; images: { runtime: string } }; - const hex = (c: string) => c.repeat(64); - const saved = { source_commit: "a".repeat(40), image_id: `sha256:${hex("a")}`, image_manifest_digest: `sha256:${hex("b")}`, microsandbox_ref: `oac-runtime@sha256:${hex("c")}`, runtime_sha256: hex("d"), firmware_sha256: hex("e") }; - await setDeployment(request, { specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: saved } }); - await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); - await expect(page.getByText(`Saved ${saved.source_commit.slice(0, 12)} · this installation serves ${served.source_commit.slice(0, 12)}`)).toBeVisible(); - const use = page.getByRole("button", { name: "Use this installation's Runtime", exact: true }); - await use.click(); - const sent = await saveConfiguration(page); - expect(sent.input.runtime).toMatchObject({ source_commit: served.source_commit, image_id: served.images.runtime }); - await expect(use).toHaveCount(0); - await inspectRollout(page, { "Core preparation": "Preparing configuration", "Target generation": "2" }); -}); - test("unknown target preparation preserves live old-generation service while an offline pin stays offline", async ({ page, request }, info) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); @@ -128,9 +112,9 @@ test("unknown target preparation preserves live old-generation service while an await setDeployment(request, { generation: 2 }); await setNode(request, { id: "node-local", online: true, provider_ready: true, rollout: { state: "unknown", ready_generation: 1 } }); await setNode(request, { id: "node-edge", online: false, provider_ready: true, rollout: { state: "unknown", ready_generation: 1 } }); - await setNode(request, { id: "node-gpu", rollout: { state: "update_required", ready_generation: 1 } }); + await setNode(request, { id: "node-gpu", rollout: { state: "failed", ready_generation: 1 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); - await inspectRollout(page, { "Node software incompatible": "1" }); + await inspectRollout(page, { "Preparation failed": "1" }); await page.getByRole("button", { name: "Nodes", exact: true }).click(); const serving = page.getByRole("row", { name: /core-01/ }); await expect(serving).toContainText("Available"); @@ -149,7 +133,7 @@ test("unknown target preparation preserves live old-generation service while an await expect(setup).toContainText("Done"); await expect(rollout(page)).toHaveCount(0); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await inspectRollout(page, { "Target generation": "2", "Target readiness unknown": "2", "Node software incompatible": "1" }); + await inspectRollout(page, { "Target generation": "2", "Target readiness unknown": "2", "Preparation failed": "1" }); await capture(page, info, "generation-configuration-summary-en", rollout(page)); expect(await writes(request)).toEqual([]); }); diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index db98679e0..ffb53a7e7 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -7,7 +7,7 @@ test("shows each node's retained limit and elapsed phase time without inventing await page.route("**/core/v1/sandbox/deployment", async (route) => { const response = await route.fetch(); const deployment = await response.json(); - await route.fulfill({ response, json: { ...deployment, provider: "microsandbox", suspension: { idle_seconds: 300, retention_seconds: 86400 }, specification: { ...deployment.specification, resources: { ...deployment.specification.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } } }); + await route.fulfill({ response, json: { ...deployment, provider: "microsandbox", suspension: { idle_seconds: 300, retention_seconds: 86400 }, specification: { ...deployment.specification, resources: { ...deployment.specification.resources, root_disk_mib: 8192 } } } }); }); await page.route("**/core/v1/sandbox/nodes", async (route) => { const response = await route.fetch(); @@ -50,7 +50,7 @@ test("edits deployment idle time without changing generation", async ({ page, re page.on("request", (sent) => { if (new URL(sent.url()).pathname.startsWith("/core/v1/sandbox/nodes")) nodeReads.push(sent.url()); }); await openConsole(page, request, "settings?id=sandbox"); const deployment = await setDeployment(request, { suspension: { idle_seconds: 300, retention_seconds: 86400 } }); - await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } }); + await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192 } } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); const area = page.getByRole("region", { name: "Automatic suspension" }); const idle = area.getByLabel("Idle before suspension (seconds)"); diff --git a/apps/web/e2e/sandbox-rollout-details.spec.ts b/apps/web/e2e/sandbox-rollout-details.spec.ts index bf9618dd2..4695ac5ee 100644 --- a/apps/web/e2e/sandbox-rollout-details.spec.ts +++ b/apps/web/e2e/sandbox-rollout-details.spec.ts @@ -9,7 +9,7 @@ test("keeps rollout summary brief and opens all Core observations in details", a await page.route("**/core/v1/sandbox/deployment", async (route) => { const response = await route.fetch(); const deployment = await response.json(); - await route.fulfill({ response, json: { ...deployment, generation: 4, resources: { allocations: 8, pending: 0 }, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } } }); + await route.fulfill({ response, json: { ...deployment, generation: 4, resources: { allocations: 8, pending: 0 }, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, unknown: 4 } } } }); }); await openConsole(page, request, "settings?id=sandbox"); const summary = page.getByRole("region", { name: "Configuration rollout", exact: true }); @@ -23,7 +23,6 @@ test("keeps rollout summary brief and opens all Core observations in details", a await expect(fact(dialog, "Target generation")).toHaveText("4"); await expect(fact(dialog, "Previous-generation sandboxes")).toHaveText("8"); await expect(fact(dialog, "Preparation failed")).toHaveText("2"); - await expect(fact(dialog, "Node software incompatible")).toHaveText("3"); await expect(fact(dialog, "Target readiness unknown")).toHaveText("4"); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); @@ -35,7 +34,7 @@ test("labels unknown target readiness without declaring settled nodes ready", as await page.route("**/core/v1/sandbox/deployment", async (route) => { const response = await route.fetch(); const deployment = await response.json(); - await route.fulfill({ response, json: { ...deployment, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 1 } } } }); + await route.fulfill({ response, json: { ...deployment, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 1 } } } }); }); await openConsole(page, request, "settings?id=sandbox"); const summary = page.getByRole("region", { name: "Configuration rollout", exact: true }); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index 052e2eb98..cf4277fac 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -38,31 +38,18 @@ test("sets, replaces and clears a harness's default model configuration, and kee } expect(await writes(request)).toEqual([]); await set.getByLabel("Base URL").fill("https://model.example/v1"); - await expect(set.getByLabel("Harness configuration (JSON)")).toBeHidden(); + // Codex accepts no native Harness configuration. await set.getByText("Advanced settings", { exact: true }).click(); - const config = set.getByLabel("Harness configuration (JSON)"); - await config.fill("[]"); - await config.blur(); - await expect(set.getByText("Enter a valid JSON object, such as {}.")).toBeVisible(); - await expect(set.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); - await config.fill('{"model_reasoning_effort":"high"}'); - await set.getByRole("button", { name: "Format JSON" }).click(); - await expect(config).toHaveValue('{\n "model_reasoning_effort": "high"\n}'); - // Limits are Core's 32-bit whole numbers, and max output needs a context window at least as large. - await set.getByLabel("Max output tokens").fill("32000"); - await expect(set.getByText("Set a context window at least this large.")).toBeVisible(); - await expect(set.getByLabel("Context window", { exact: true })).toHaveAttribute("aria-invalid", "true"); - await expect(set.getByLabel("Context window", { exact: true })).toHaveAccessibleDescription(/Set a context window at least this large/); - await expect(set.getByLabel("Max output tokens", { exact: true })).not.toHaveAttribute("aria-invalid", "true"); - await set.getByLabel("Context window", { exact: true }).fill("1000"); - await expect(set.getByLabel("Context window", { exact: true })).toHaveAttribute("aria-invalid", "true"); + await expect(set.getByLabel("Harness configuration (JSON)")).toHaveCount(0); + // Codex has no native output limit; limits are Core's 32-bit whole numbers. + await expect(set.getByLabel("Max output tokens")).toHaveCount(0); await set.getByLabel("Context window").fill("2147483648"); await expect(set.getByText("Enter a whole number up to 2147483647.")).toBeVisible(); await expect(set.getByRole("button", { name: "Save" })).toBeDisabled(); await set.getByLabel("Context window").fill("200000"); await set.getByRole("button", { name: "Save" }).click(); await expect(set).toBeHidden(); - for (const text of ["Ready", "OpenAI Responses", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text); + for (const text of ["Ready", "OpenAI Responses", "https://model.example/v1", "Configured", "200,000"]) await expect(codex).toContainText(text); expect(await page.content()).not.toContain(KEY); // Replacing starts from the saved fields but never from the key; closing the form forgets a typed key. @@ -70,30 +57,19 @@ test("sets, replaces and clears a harness's default model configuration, and kee let replace = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); await expect(replace.getByLabel("Default model ID")).toHaveValue("fixture-model"); - await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue(/model_reasoning_effort/); await expect(replace.getByLabel("Base URL")).toHaveValue("https://model.example/v1"); await expect(replace.getByLabel("API key")).toHaveValue(""); await expect(replace.getByRole("button", { name: "Save" })).toBeDisabled(); await replace.getByLabel("API key").fill(KEY); - const nativeSettings = replace.getByLabel("Harness configuration (JSON)"); - await expect(nativeSettings).toHaveValue(/model_reasoning_effort/); - await replace.getByLabel("Default model ID").fill("different-model"); - await expect(nativeSettings).toHaveValue("{}"); - await nativeSettings.fill('{"model_reasoning_effort":"low"}'); - await replace.getByLabel("Base URL").fill("https://another-model.example/v1"); - await expect(nativeSettings).toHaveValue("{}"); - await nativeSettings.fill('{"model_reasoning_effort":"high"}'); await replace.getByRole("button", { name: "Cancel" }).click(); expect(await page.content()).not.toContain(KEY); await codex.getByRole("button", { name: "Replace the default model configuration for Codex" }).click(); replace = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); await expect(replace.getByLabel("Default model ID")).toHaveValue("fixture-model"); - await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue(/model_reasoning_effort/); await expect(replace.getByLabel("API key")).toHaveValue(""); await replace.getByLabel("Base URL").fill("https://model.example/v2"); await replace.getByLabel("API key").fill(KEY); - await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue("{}"); // Enter saves. await replace.getByLabel("API key").press("Enter"); await expect(replace).toBeHidden(); @@ -106,7 +82,40 @@ test("sets, replaces and clears a harness's default model configuration, and kee await expect(confirm).toBeHidden(); await expect(codex).toContainText("Model provider not set"); - // MiniMax Code needs both limits; the form shows Core's reason. A disabled harness may still be configured. + // Claude Code accepts the native `thinking` configuration: invalid JSON blocks saving, and the values follow the model and URL they were set for. + await section.getByRole("article", { name: "Claude Code" }).getByRole("button", { name: "Set the default model configuration for Claude Code" }).click(); + const claude = page.getByRole("dialog", { name: "Set default model configuration for Claude Code" }); + await claude.getByRole("combobox", { name: "Protocol", exact: true }).click(); + await page.getByRole("option", { name: "Anthropic Messages", exact: true }).click(); + await claude.getByLabel("Base URL").fill("https://model.example/anthropic"); + await claude.getByLabel("API key").fill(KEY); + await claude.getByLabel("Default model ID").fill("fixture-model"); + await expect(claude.getByLabel("Harness configuration (JSON)")).toBeHidden(); + await claude.getByText("Advanced settings", { exact: true }).click(); + const config = claude.getByLabel("Harness configuration (JSON)"); + await config.fill("[]"); + await config.blur(); + await expect(claude.getByText("Enter a valid JSON object, such as {}.")).toBeVisible(); + await expect(claude.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await config.fill('{"thinking":{"type":"adaptive"}}'); + await claude.getByRole("button", { name: "Format JSON" }).click(); + await expect(config).toHaveValue('{\n "thinking": {\n "type": "adaptive"\n }\n}'); + await claude.getByRole("button", { name: "Save", exact: true }).click(); + await expect(claude).toBeHidden(); + await section.getByRole("article", { name: "Claude Code" }).getByRole("button", { name: "Replace the default model configuration for Claude Code" }).click(); + const claudeReplace = page.getByRole("dialog", { name: "Replace default model configuration for Claude Code" }); + const saved = claudeReplace.getByLabel("Harness configuration (JSON)"); + await expect(saved).toHaveValue(/adaptive/); + await claudeReplace.getByLabel("API key").fill(KEY); + await claudeReplace.getByLabel("Default model ID").fill("different-model"); + await expect(saved).toHaveValue("{}"); + await saved.fill('{"thinking":{"type":"disabled"}}'); + await claudeReplace.getByLabel("Base URL").fill("https://another-model.example/anthropic"); + await expect(saved).toHaveValue("{}"); + await claudeReplace.getByRole("button", { name: "Cancel" }).click(); + expect(await page.content()).not.toContain(KEY); + + // A disabled harness may still be configured; token limits are optional. await mcode.getByRole("button", { name: "Set the default model configuration for MiniMax Code" }).click(); const limits = page.getByRole("dialog", { name: "Set default model configuration for MiniMax Code" }); await expect(limits.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("Anthropic Messages"); @@ -116,7 +125,16 @@ test("sets, replaces and clears a harness's default model configuration, and kee await limits.getByLabel("Base URL").fill("https://model.example/anthropic"); await limits.getByLabel("API key").fill(KEY); await limits.getByLabel("Default model ID").fill("fixture-model"); - await expect(limits.getByRole("button", { name: "Save" })).toBeDisabled(); + // Max output alone is valid and must fit a set context window. + await limits.getByText("Advanced settings", { exact: true }).click(); + await limits.getByLabel("Max output tokens").fill("32000"); + await expect(limits.getByLabel("Context window", { exact: true })).not.toHaveAttribute("aria-invalid", "true"); + await limits.getByLabel("Context window", { exact: true }).fill("1000"); + await expect(limits.getByText("Set a context window at least this large.")).toBeVisible(); + await expect(limits.getByLabel("Context window", { exact: true })).toHaveAccessibleDescription(/Set a context window at least this large/); + await expect(limits.getByLabel("Max output tokens", { exact: true })).not.toHaveAttribute("aria-invalid", "true"); + await limits.getByLabel("Context window", { exact: true }).fill(""); + await expect(limits.getByRole("button", { name: "Save" })).toBeEnabled(); await limits.getByRole("button", { name: "Cancel" }).click(); await expect(mcode).toContainText("Not set"); @@ -124,6 +142,7 @@ test("sets, replaces and clears a harness's default model configuration, and kee "PUT /core/v1/harnesses/codex/model-configuration", "PUT /core/v1/harnesses/codex/model-configuration", "DELETE /core/v1/harnesses/codex/model-configuration", + "PUT /core/v1/harnesses/claude_sdk/model-configuration", ]); const browserState = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage }, url: location.href, cookie: document.cookie })); expect(browserState).not.toContain(KEY); @@ -154,7 +173,6 @@ test("requires an explicit supported protocol choice for an older default config const data = await response.json(); const codex = data.data.find((harness: { id: string }) => harness.id === "codex"); codex.model_configuration.model_provider.protocol = "chat_completions"; - codex.model_configuration.harness_config = { model_reasoning_effort: "high" }; await route.fulfill({ response, json: data }); }); await openConsole(page, request, "settings?id=models"); @@ -173,7 +191,7 @@ test("requires an explicit supported protocol choice for an older default config await expect(page.getByRole("option")).toHaveText(["OpenAI Responses"]); await page.getByRole("option", { name: "OpenAI Responses", exact: true }).click(); await expect(dialog.getByRole("alert")).toHaveCount(0); - await expect(dialog.getByLabel("Harness configuration (JSON)")).toHaveValue("{}"); + await expect(dialog.getByLabel("Harness configuration (JSON)")).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); expect(await writes(request)).toEqual([]); }); diff --git a/apps/web/public/onboarding/monitor-en.webp b/apps/web/public/onboarding/monitor-en.webp index 70aab640b..61873b45a 100644 Binary files a/apps/web/public/onboarding/monitor-en.webp and b/apps/web/public/onboarding/monitor-en.webp differ diff --git a/apps/web/public/onboarding/monitor-zh.webp b/apps/web/public/onboarding/monitor-zh.webp index 0d223ac70..ea98ed16f 100644 Binary files a/apps/web/public/onboarding/monitor-zh.webp and b/apps/web/public/onboarding/monitor-zh.webp differ diff --git a/apps/web/public/onboarding/platform-en.webp b/apps/web/public/onboarding/platform-en.webp index 490cf5e87..ce208ca41 100644 Binary files a/apps/web/public/onboarding/platform-en.webp and b/apps/web/public/onboarding/platform-en.webp differ diff --git a/apps/web/public/onboarding/platform-zh.webp b/apps/web/public/onboarding/platform-zh.webp index d77f29b8a..92b25e43c 100644 Binary files a/apps/web/public/onboarding/platform-zh.webp and b/apps/web/public/onboarding/platform-zh.webp differ diff --git a/apps/web/public/onboarding/resources-en.webp b/apps/web/public/onboarding/resources-en.webp index 3660c2922..c4e9c9a17 100644 Binary files a/apps/web/public/onboarding/resources-en.webp and b/apps/web/public/onboarding/resources-en.webp differ diff --git a/apps/web/public/onboarding/resources-zh.webp b/apps/web/public/onboarding/resources-zh.webp index de269a8ee..0e17ec4a5 100644 Binary files a/apps/web/public/onboarding/resources-zh.webp and b/apps/web/public/onboarding/resources-zh.webp differ diff --git a/apps/web/src/features/environment-templates/TemplateDetail.tsx b/apps/web/src/features/environment-templates/TemplateDetail.tsx index b7bcaf7a7..0f0538e07 100644 --- a/apps/web/src/features/environment-templates/TemplateDetail.tsx +++ b/apps/web/src/features/environment-templates/TemplateDetail.tsx @@ -9,7 +9,7 @@ import type { EnvironmentTemplateSkill, } from "@oac/agents-client"; -import { PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; +import { HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { formatBytes, formatDateTime, MISSING } from "../../lib/format"; import { CopyableId, CopyIdButton } from "../../components/list-ui"; import { templateName } from "./template-name"; @@ -169,12 +169,12 @@ export function TemplateDetailPage({
{t("detail.updated")}
{formatDateTime(template.updated_at, locale)}
{t("detail.network")}
-
{network ? t(`access.${network.access}`) : }
+
{network ? <>{t(`access.${network.access}`)}{network.access !== "enabled" ? {t("accessHelp")} : null} : }
{network?.access === "restricted" ? ( -
{t("detail.allowedDomains")} } help={t("accessHelp.restricted")}> +
{t("detail.allowedDomains")} }>
) : null} diff --git a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx index 427c1c816..0cb7598a1 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx @@ -6,7 +6,7 @@ import { SandboxResetNotice } from "./SandboxResetNotice"; const reset: SandboxReset = { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 2, on_offline_nodes: 1, offline_nodes: [{ node_id: "n1", name: "Node 1", resources: 1 }] } }; -const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, pending: 0 }, suspension: null }); +const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, pending: 0 }, suspension: null }); const render = (value: SandboxDeployment | undefined, failed = false) => renderToStaticMarkup( {}} />); describe("reset notices on read-only surfaces", () => { diff --git a/apps/web/src/features/fleet/fleet-model.test.ts b/apps/web/src/features/fleet/fleet-model.test.ts index c8de45dbc..700ea2250 100644 --- a/apps/web/src/features/fleet/fleet-model.test.ts +++ b/apps/web/src/features/fleet/fleet-model.test.ts @@ -15,7 +15,7 @@ describe("fleet model", () => { it("classifies node health", () => { expect(nodeHealth(node("a"))).toBe("available"); - expect(nodeHealth(node("a", { diagnostic: "provider_unavailable" }))).toBe("degraded"); + expect(nodeHealth(node("a", { provider_ready: false }))).toBe("degraded"); expect(nodeHealth(node("a", { online: false }))).toBe("offline"); }); diff --git a/apps/web/src/features/fleet/fleet-model.ts b/apps/web/src/features/fleet/fleet-model.ts index 48cd3a1c6..cbbdbc1bf 100644 --- a/apps/web/src/features/fleet/fleet-model.ts +++ b/apps/web/src/features/fleet/fleet-model.ts @@ -10,7 +10,7 @@ export type NodeHealth = "available" | "degraded" | "offline"; export function nodeHealth(node: SandboxNode): NodeHealth { if (!node.online) return "offline"; - return node.provider_ready && !node.diagnostic ? "available" : "degraded"; + return node.provider_ready ? "available" : "degraded"; } /** Live provider evidence is independent of target preparation and durable serving pins. */ diff --git a/apps/web/src/features/fleet/fleet-queries.test.ts b/apps/web/src/features/fleet/fleet-queries.test.ts index 963af5991..e0eb11cc1 100644 --- a/apps/web/src/features/fleet/fleet-queries.test.ts +++ b/apps/web/src/features/fleet/fleet-queries.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxScope } from "../sandbox/sandbox-queries"; import { fleetQuery } from "./fleet-queries"; -const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index c97c61b5e..756ae7660 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -10,7 +10,7 @@ import { fleetQuery, type FleetSnapshot } from "./fleet-queries"; import { FleetReadNotice } from "./FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "./use-sandbox-fleet"; -const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; function Probe() { const { state, deployment } = useSandboxFleet(); @@ -51,7 +51,7 @@ describe("fleet evidence after deployment changes", () => { }); it("retains older resource evidence across an online generation change with an explicit qualification", () => { - const html = render({ ...configured, generation: 2, rollout: { state: "preparing", previous_generation_sandboxes: 1, nodes: { ready: 0, preparing: 1, failed: 0, update_required: 0, unknown: 0 } } }); + const html = render({ ...configured, generation: 2, rollout: { state: "preparing", previous_generation_sandboxes: 1, nodes: { ready: 0, preparing: 1, failed: 0, unknown: 0 } } }); expect(html).toContain('data-fleet="ready"'); expect(html).toContain("1 nodes / 1 allocations"); expect(html).toContain("configuration generation 1; the current target is 2"); diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index 2fc0a9297..b0232180e 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -1,6 +1,5 @@ -import { AgentCoreError, type CoreJobStatus, type CoreMetrics, type CoreMetricsRange } from "@oac/agents-client"; +import { type CoreJobStatus, type CoreMetrics, type CoreMetricsRange } from "@oac/agents-client"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Network } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -55,17 +54,14 @@ export function CoreMetricsPage() { const [range, setRange] = useState("1h"); const query = useQuery({ ...coreMetricsQuery(range), placeholderData: keepPreviousData, refetchInterval: REFRESH_MS, refetchIntervalInBackground: false }); const metrics = query.data ?? null; - const missing = query.error instanceof AgentCoreError && (query.error.status === 404 || query.error.status === 501); const error = query.isError ? coreError(query.error, tCommon) : ""; useFailureToast(metrics !== null && query.isError, t("core.stale", { reason: error }), "core-metrics-refresh"); let body: ReactNode; if (!metrics) { - body = missing - ? - : query.isError - ?

{t("core.failed", { reason: error })}

- : ; + body = query.isError + ?

{t("core.failed", { reason: error })}

+ : ; } else { body = ; } diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 97249ca59..3952215bf 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -6,17 +6,17 @@ import { checklistStorageKey, checklistView, gettingStartedSteps, nextStepAfterN import { node, project } from "./test-fixtures"; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, - rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", + rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, suspension: null, ...overrides, }); const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, targetGeneration: value.generation, refreshing: false, error: null }); const sandboxes = (state: FleetState) => gettingStartedSteps({ sandboxReset: false, fleet: state, localOnly: false, projects: [], sessions: 0, harnesses: [] }).sandboxes; const provider = { object: "core.model_configuration", model: "fixture-model", harness_config: {}, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: "2026-09-25T00:00:00Z" } as const; -const harness = (id: CoreHarness["id"], fields: Partial = {}): CoreHarness => ({ object: "core.harness", model_configuration_support: { protocols: ["responses"], accepts_harness_config: true, token_limits_required: false }, id, enabled: true, default: false, model_configuration: null, ...fields }); +const harness = (id: CoreHarness["id"], fields: Partial = {}): CoreHarness => ({ object: "core.harness", model_configuration_support: { protocols: ["responses"], accepts_harness_config: true, accepts_max_output_tokens: false }, id, enabled: true, default: false, model_configuration: null, ...fields }); describe("Getting started steps", () => { it("uses live provider readiness independently of target state or a durable pin", () => { - for (const state of ["preparing", "failed", "update_required", "unknown"] as const) { + for (const state of ["preparing", "failed", "unknown"] as const) { const serving = node("n1", { rollout: { state, ready_generation: 1 } }); expect(sandboxes(fleet(deployment({ generation: 2 }), [serving])).state).toBe("done"); expect(sandboxes(fleet(deployment({ generation: 2 }), [{ ...serving, provider_ready: false }])).state).toBe("todo"); diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index e11dfcea3..135f60cae 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -39,7 +39,7 @@ export function node(id: string, overrides: Partial = {}): SandboxN rollout: { state: overrides.online === false ? "unknown" : "ready", ready_generation: 1 }, cpu_count: 8, available_memory_bytes: 1024, available_disk_bytes: 2048, running: 1, snapshots: 0, last_seen_at: "2026-09-24T00:00:00Z", - max_active: 4, max_retained: 8, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, + max_active: 4, max_retained: 8, recommended_capacity: null, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", enrollment_id: null, ...overrides, }; diff --git a/apps/web/src/features/sandbox/NodeCapacityFields.tsx b/apps/web/src/features/sandbox/NodeCapacityFields.tsx index 67b21e05f..24e100866 100644 --- a/apps/web/src/features/sandbox/NodeCapacityFields.tsx +++ b/apps/web/src/features/sandbox/NodeCapacityFields.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; @@ -11,14 +10,14 @@ export function nodeCapacity(active: string, retained: string) { retainedError: !valid(maxRetained) ? "Enter a whole number from 1 to 1,000,000." as const : valid(maxActive) && maxRetained < maxActive ? "Enter at least the number of sandboxes at once." as const : null }; } -export function NodeCapacityFields({ id, active, retained, suspends, activeError, retainedError, onActive, onRetained, autoFocus, help }: { +export function NodeCapacityFields({ id, active, retained, suspends, activeError, retainedError, onActive, onRetained, autoFocus }: { id: string; active: string; retained: string; suspends: boolean; activeError: string | null; retainedError: string | null; - onActive: (value: string) => void; onRetained: (value: string) => void; autoFocus?: boolean; help?: ReactNode; + onActive: (value: string) => void; onRetained: (value: string) => void; autoFocus?: boolean; }) { const { t } = useTranslation("sandbox"); return <>
- {t("The most sandboxes Core places on this node at the same time.")}{help} + {t("The most sandboxes Core places on this node at the same time.")} onActive(event.target.value)} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} /> {activeError ? {activeError} : null}
diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 554bbb41c..7768dbad2 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -1,29 +1,22 @@ import { useId, useState } from "react"; -import type { SandboxAdminClient, SandboxNode, SandboxResources } from "@oac/agents-client"; -import { useQuery } from "@tanstack/react-query"; +import type { SandboxAdminClient, SandboxNode } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { nodeCapacity, NodeCapacityFields } from "./NodeCapacityFields"; import { Modal } from "../../components/Modal"; import { coreFieldError } from "../../lib/core-error"; -import { formatBytes } from "../../lib/format"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { nodeDetailQuery } from "../fleet/fleet-queries"; -import { sandboxesThatFit } from "./deployment-specification"; /** * A node's name and sandbox limits (`PATCH /core/v1/sandbox/nodes/{id}`). Core * takes all three together. Only a deployment whose Provider suspends sandboxes * shows the retained limit; otherwise the saved one is kept, raised to at least - * the active limit because Core requires it. Under the limit, the host's CPUs - * and memory from the node's last heartbeat, each sandbox's size and how many - * of those the host holds. + * the active limit because Core requires it. Beneath the fields, Core's + * recommendation for the node's host, which one action fills in. */ -export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved }: { +export function NodeEditDialog({ client, node, suspends, onClose, onSaved }: { client: SandboxAdminClient; node: SandboxNode | null; - /** Each sandbox's CPUs and memory, from the deployment. */ - size: SandboxResources | null; /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ suspends: boolean; onClose: () => void; @@ -34,8 +27,8 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved const { t: tCommon } = useTranslation("common"); const id = useId(); const [name, setName] = useState(node?.name ?? ""); - const [active, setActive] = useState(String(node?.max_active ?? 2)); - const [retained, setRetained] = useState(String(node?.max_retained ?? 8)); + const [active, setActive] = useState(String(node?.max_active ?? "")); + const [retained, setRetained] = useState(String(node?.max_retained ?? "")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const limits = nodeCapacity(active, suspends ? retained : String(Math.max(node?.max_retained ?? 0, Number(active) || 0))); @@ -48,19 +41,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved const activeError = coreFieldError(error, "max_active", tCommon) ?? activeProblem; const retainedError = coreFieldError(error, "max_retained", tCommon) ?? retainedProblem; const ready = node !== null && !nameProblem && !activeProblem && !retainedProblem && !busy; - // The node detail read adds the host's total memory to its CPU count. - const detail = useQuery({ ...nodeDetailQuery(node?.id ?? "", "1h"), enabled: node !== null }); - const host = detail.data?.id === node?.id ? detail.data?.host : undefined; - const hostCpus = host?.effective_cpu_cores ?? node?.cpu_count ?? null; - const hostMemory = host?.total_memory_bytes ?? null; - const fit = sandboxesThatFit({ cpus: hostCpus, memoryBytes: hostMemory }, size); - const measure = (cpus: number, memory: number) => t("{{cpus}} CPU · {{memory}}", { cpus, memory: formatBytes(memory) }); - // Sentences run on with a space in English and without one in Chinese. - const hostFacts = hostCpus !== null && hostMemory !== null ? [ - t("Host: {{host}}.", { host: measure(hostCpus, hostMemory) }), - ...(size ? [t("Each sandbox: {{size}}.", { size: measure(size.cpus, size.memory_mib * 2 ** 20) })] : []), - ...(fit !== null && fit > 0 ? [t("Suggested: at most {{count}} at once.", { count: fit })] : []), - ].join(locale === "zh-CN" ? "" : " ") : null; + const recommended = node?.recommended_capacity ?? null; async function save() { if (!ready || !node) return; @@ -92,7 +73,11 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved { setName(event.target.value); setError(null); }} autoComplete="off" aria-invalid={Boolean(nameError)} aria-describedby={nameError ? `${id}-name-error` : undefined} /> {nameError ? {nameError} : null} - {hostFacts} : null} onActive={(value) => { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> + { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> + {recommended ?

+ {suspends ? t("Recommended: {{active}} at once, {{retained}} retained.", { active: recommended.max_active, retained: recommended.max_retained }) : t("Recommended: {{active}} at once.", { active: recommended.max_active })}{" "} + +

: null} {error !== null ?

{sandboxRequestError(error, locale)}

: null} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 164a65e2b..3b46d2d83 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@oac/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; +import { HelpTip, SegmentedControl, StatusDot, type Tone } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; @@ -30,13 +30,10 @@ function rememberRequirementsSeen() { try { window.localStorage.setItem(REQUIREMENTS_SEEN, "1"); } catch { /* Storage can be unavailable; the list then opens each time. */ } } -/** The limits a new flow starts from. */ -const DEFAULT_ACTIVE = "2"; -const DEFAULT_RETAINED = "8"; - /** - * Add node: the administrator sets the node's sandbox limits, then Core issues a - * one-time enrollment command that approves them + * Add node: the administrator keeps the recommended limits, which Core derives + * from the host when the node registers, or enters both numbers; then Core + * issues a one-time enrollment command * (`POST /core/v1/sandbox/enrollment-tokens`). Only a deployment with a * suspension policy, which its Provider's checkpoint support declares, asks for * a retained limit; otherwise a node retains exactly the sandboxes it runs at @@ -73,8 +70,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { t: tCommon } = useTranslation("common"); const id = useId(); - const [active, setActive] = useState(DEFAULT_ACTIVE); - const [retained, setRetained] = useState(DEFAULT_RETAINED); + const [custom, setCustom] = useState(false); + const [active, setActive] = useState(""); + const [retained, setRetained] = useState(""); const [busy, setBusy] = useState(false); const [enrollment, setEnrollment] = useState(null); const [appeared, setAppeared] = useState<{ id: string; at: number } | null>(null); @@ -116,9 +114,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const retainedLimit = limits.retained; const activeProblem = limits.activeError ? t(limits.activeError) : null; const retainedProblem = suspends && limits.retainedError ? t(limits.retainedError) : null; - const activeError = coreFieldError(error, "max_active", tCommon) ?? activeProblem; - const retainedError = coreFieldError(error, "max_retained", tCommon) ?? retainedProblem; - const limitsReady = !activeProblem && !retainedProblem; + // An empty field is not an error yet; it only keeps Generate command off. + const activeError = coreFieldError(error, "max_active", tCommon) ?? (active.trim() ? activeProblem : null); + const retainedError = coreFieldError(error, "max_retained", tCommon) ?? (retained.trim() ? retainedProblem : null); + const limitsReady = !custom || (!activeProblem && !retainedProblem); const node = enrollment ? enrolledNode(nodes, enrollment) : null; const progress = enrollmentProgress(node, node && appeared?.id === node.id ? appeared.at : undefined, now); // Registration uses the token, so from then on its expiry no longer matters; rerunning @@ -188,7 +187,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // A connected node ends the command; otherwise it waits here for the next opening. if (ready) { setEnrollment(null); setAppeared(null); } // The limits, and which command was copied, stay only with an unfinished flow: a command still waiting for its node. - if (!enrollment || ready) { setActive(DEFAULT_ACTIVE); setRetained(DEFAULT_RETAINED); } + if (!enrollment || ready) { setCustom(false); setActive(""); setRetained(""); } onClose(); } /** @@ -201,8 +200,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, setEnrollment(null); setAppeared(null); setError(null); } async function generate() { - if (request.current || !fresh || !available || blocker || !limitsReady || activeLimit === null || retainedLimit === null) return; - const capacity = { max_active: activeLimit, max_retained: retainedLimit }; + if (request.current || !fresh || !available || blocker || !limitsReady) return; + const capacity = custom ? { max_active: activeLimit!, max_retained: retainedLimit! } : {}; generation.current++; const controller = new AbortController(); request.current = controller; setBusy(true); setError(null); @@ -270,8 +269,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, :

{blocker.text}

: !enrollment ? (
{ event.preventDefault(); void generate(); }}> -

{t("Set the sandbox limits for the host you want to add.")}

- { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> + { setCustom(value === "custom"); setError(null); }} /> + {custom ? { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> : null} {error !== null ?

{sandboxRequestError(error, locale)}

: null} {requirements} diff --git a/apps/web/src/features/sandbox/NodeList.test.ts b/apps/web/src/features/sandbox/NodeList.test.ts index d90258500..5aa689af4 100644 --- a/apps/web/src/features/sandbox/NodeList.test.ts +++ b/apps/web/src/features/sandbox/NodeList.test.ts @@ -10,7 +10,7 @@ const core = "https://core.example"; describe("node state", () => { it("keeps live serving health independent of target preparation and durable pins", () => { - for (const state of ["unknown", "preparing", "failed", "update_required"] as const) { + for (const state of ["unknown", "preparing", "failed"] as const) { const serving = node("a", { online: true, provider_ready: true, rollout: { state, ready_generation: 1 } }); expect(nodeState(serving, [], false, core)).toBe("available"); expect(nodeState({ ...serving, online: false }, [], false, core)).toBe("offline"); diff --git a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx index 4e41978a8..02205b850 100644 --- a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx +++ b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx @@ -1,11 +1,11 @@ import type { SandboxNode, SandboxNodeRollout } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; -import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; +import { StatusDot, type Tone } from "../../components/console-ui"; import type { ParseKeys } from "i18next"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; -const labels: Record> = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", update_required: "Node software incompatible", unknown: "Target readiness unknown" }; -const tones: Record = { ready: "ok", preparing: "neutral", failed: "warning", update_required: "warning", unknown: "neutral" }; +const labels: Record> = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", unknown: "Target readiness unknown" }; +const tones: Record = { ready: "ok", preparing: "neutral", failed: "warning", unknown: "neutral" }; /** A serving pin is historical ownership, not proof of a live connection or capacity. */ export function NodeRolloutStatus({ node, stale = false }: { node: SandboxNode; stale?: boolean }) { @@ -14,6 +14,5 @@ export function NodeRolloutStatus({ node, stale = false }: { node: SandboxNode; return {state === "failed" && node.rollout.diagnostic ? : null} - {state === "update_required" ? {t("This node can't prepare new sandbox configurations and keeps serving the one it registered with. To move it to the current configuration, choose Remove node, then add the host again with Add node.")} : null} ; } diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 3b1affcaf..a41e04ded 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -1,15 +1,13 @@ import { useState, type ReactNode } from "react"; -import type { UpdateSandboxDeployment, SandboxDeployment, SandboxRuntimeRelease } from "@oac/agents-client"; -import { useQuery } from "@tanstack/react-query"; -import { Trans, useTranslation } from "react-i18next"; +import type { UpdateSandboxDeployment, SandboxDeployment } from "@oac/agents-client"; +import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { HelpTip, RefreshButton } from "../../components/console-ui"; import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; import type { ParseKeys } from "i18next"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; import { Fact } from "../system/Fact"; -import { RUNTIME_RELEASE_FIELDS, sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; -import { distributionRuntimeQuery } from "./sandbox-queries"; +import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; @@ -31,21 +29,14 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const [changing, setChanging] = useState(false); const [editKey, setEditKey] = useState(0); - const [proposedRuntime, setProposedRuntime] = useState(); const spec = deployment.specification; - // Only Docker and microsandbox save a Runtime. Changes keep it; moving to the - // one this console serves is an explicit change of its own. - const savedRuntime = spec?.runtime; - const served = useQuery({ ...distributionRuntimeQuery, enabled: Boolean(savedRuntime) }).data; - const offered = savedRuntime && served && !deployment.reset && RUNTIME_RELEASE_FIELDS.some((field) => savedRuntime[field] !== served[field]) ? served : null; // An E2B selection may adopt its template build's size instead of saving one. const size = sandboxSize(deployment); const build = deployment.metadata?.template_build; const buildSize = templateBuildSize(deployment); const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); const canEdit = fresh && !deployment.reset; - const change = (runtime?: SandboxRuntimeRelease) => { setEditKey((key) => key + 1); setProposedRuntime(runtime); setChanging(true); }; - const short = (release: SandboxRuntimeRelease) => {release.source_commit.slice(0, 12)}; + const change = () => { setEditKey((key) => key + 1); setChanging(true); }; return
@@ -54,7 +45,6 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat {t("One provider serves this deployment. Reset it before choosing a different backend.")} {deployment.mode === "direct" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""}
- {spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null}
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
@@ -63,15 +53,12 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
{!deployment.reset ?
{t("Changes keep this backend and existing Sessions and nodes. Core prepares the new target for future work; placement may continue on qualified earlier generations.")} - +
: null}
{sandboxProviderLabel(deployment.provider, locale)} - {size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${spec?.workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING} - {savedRuntime && offered ? change(offered)}>{t("Use this installation's Runtime")}}> - - : null} + {size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) })}` : ""}` : MISSING} {deployment.provider === "e2b" ? <> {deployment.configuration?.api_url} {deployment.configuration?.domain} @@ -92,7 +79,6 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined} disabled={disabled || !canEdit} editing - proposedRuntime={proposedRuntime} onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }} /> {writeFailure} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 4995d8654..1406a33f0 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -18,7 +18,6 @@ import { NodeList, onOldAddress } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; import { NodeEnrollment } from "./NodeEnrollment"; import { NodeEditDialog } from "./NodeEditDialog"; -import { sandboxSize } from "./deployment-specification"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; import "./SandboxManagerView.css"; @@ -142,7 +141,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig key={editTarget?.id ?? "closed"} client={client} node={editTarget} - size={snapshot ? sandboxSize(snapshot.deployment) : null} suspends={suspends} onClose={() => setEditId(null)} onSaved={() => { diff --git a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx index 7d95ba13c..c5184de13 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx @@ -5,7 +5,7 @@ import { SandboxResetControls } from "./SandboxResetControls"; const reset: SandboxReset = { clear: "auto", requested_at: "2020-01-01T10:00:00Z", deadline_at: "2020-01-01T11:00:00Z", forced_at: null, remaining: { busy: 2, idle: 1, cleanup: 3, on_offline_nodes: 2, offline_nodes: [{ node_id: "offline-a", name: "Offline A", resources: 2 }] } }; -const deployment = (value: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, pending: 1 }, suspension: null }); +const deployment = (value: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, pending: 1 }, suspension: null }); const render = (value: SandboxReset | null, stale = false) => renderToStaticMarkup( true} onCancel={async () => true} />); describe("authoritative reset progress", () => { diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index a6d724701..0364ada21 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -8,7 +8,7 @@ import { NodeRolloutStatus } from "./NodeRolloutStatus"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "i", owner_epoch: 1, generation: 4, provider: "docker", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, pending: 2 }, reset: null, suspension: null, - rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } }; + rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, unknown: 4 } } }; describe("authoritative configuration rollout", () => { it("keeps attention visible while moving detailed counts off the main page", () => { @@ -30,14 +30,14 @@ describe("authoritative configuration rollout", () => { }); it("does not call settled nodes ready when Core reports unknown target readiness", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain("Target readiness unknown"); expect(html).not.toContain("No active preparation"); expect(html).not.toContain("Ready for target"); }); it("reports preparing and settled without turning settled into readiness", () => { - const quiet = { ...deployment, rollout: { state: "settled" as const, previous_generation_sandboxes: 0, nodes: { ready: 2, preparing: 0, failed: 0, update_required: 0, unknown: 0 } } }; + const quiet = { ...deployment, rollout: { state: "settled" as const, previous_generation_sandboxes: 0, nodes: { ready: 2, preparing: 0, failed: 0, unknown: 0 } } }; expect(renderToStaticMarkup()).toContain("No active preparation"); expect(renderToStaticMarkup()).toContain("Preparing configuration"); expect(renderToStaticMarkup()).toBe(""); diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx index 67f3c521f..3749f300f 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx @@ -13,7 +13,7 @@ export function SandboxRolloutSummary({ deployment, stale = false, onOpen }: { d const [open, setOpen] = useState(false); if (!deployment.provider || deployment.reset) return null; const { rollout } = deployment; - const needsAttention = !!rollout.nodes && (rollout.nodes.failed > 0 || rollout.nodes.update_required > 0); + const needsAttention = !!rollout.nodes && (rollout.nodes.failed > 0); const unknown = !!rollout.nodes && rollout.nodes.unknown > 0; const label = stale ? "Rollout state unconfirmed" : needsAttention ? "Needs attention" : unknown ? "Target readiness unknown" : rollout.state === "preparing" ? "Preparing configuration" : "No active preparation"; const tone: Tone = stale || needsAttention || unknown ? "warning" : rollout.state === "preparing" ? "pending" : "neutral"; @@ -32,7 +32,7 @@ export function SandboxRolloutSummary({ deployment, stale = false, onOpen }: { d {t(stale ? "Rollout state is unconfirmed. These are the last confirmed observations." : rollout.state === "settled" ? "Core reports no active preparation. This does not mean every node is ready." : "Core is preparing the target configuration.")} - {needsAttention || unknown ? ` ${t("Review affected nodes for preparation errors, incompatible node software or an unconfirmed connection. Qualified earlier generations may still serve work.")}` : null} + {needsAttention || unknown ? ` ${t("Review affected nodes for preparation errors or an unconfirmed connection. Qualified earlier generations may still serve work.")}` : null}
@@ -43,7 +43,6 @@ export function SandboxRolloutSummary({ deployment, stale = false, onOpen }: { d
{t("Ready for target")}
{count(rollout.nodes.ready)}
{t("Preparing target")}
{count(rollout.nodes.preparing)}
{t("Preparation failed")}
{count(rollout.nodes.failed)}
-
{t("Node software incompatible")}
{count(rollout.nodes.update_required)}
{t("Target readiness unknown")}
{count(rollout.nodes.unknown)}
: null}
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 7644af628..45fad9877 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; @@ -15,9 +15,10 @@ import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; -import { defaultSandboxResources, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification"; +import { workspaceStorageQuery } from "../settings/workspace-storage"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; -import { distributionRuntimeQuery, sandboxAdmin } from "./sandbox-queries"; +import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; type Where = "nodes" | "direct"; @@ -70,30 +71,21 @@ export function validEndpoint(apiURL: string, domain: string): boolean { * double of it, disks included where the default declares them. A Provider * whose configuration selects the size declares none and has no presets. */ -function presets(provider: SandboxProvider, workspace?: SandboxSpecification["workspace"]): Record | null { - const standard = defaultSandboxResources(provider, workspace); +function presets(provider: SandboxProvider): Record | null { + const standard = defaultSandboxResources(provider); if (!standard) return null; const scale = (factor: number): SandboxResources => ({ cpus: Math.max(1, standard.cpus * factor), memory_mib: standard.memory_mib * factor, - ...(standard.root_disk_mib ? { root_disk_mib: standard.root_disk_mib * factor, environment_disk_mib: standard.environment_disk_mib! * factor } : {}), + ...(standard.root_disk_mib ? { root_disk_mib: standard.root_disk_mib * factor } : {}), }); return { small: scale(0.5), standard, large: scale(2) }; } -const releaseLabels: Record> = { - source_commit: "Source commit", - image_id: "Image ID", - image_manifest_digest: "Image manifest digest", - microsandbox_ref: "microsandbox reference", - runtime_sha256: "Runtime SHA-256", - firmware_sha256: "Firmware SHA-256", -}; - -function presetOf(provider: SandboxProvider, resources: SandboxResources, workspace?: SandboxSpecification["workspace"]): Preset | null { +function presetOf(provider: SandboxProvider, resources: SandboxResources): Preset | null { const same = (a: SandboxResources, b: SandboxResources) => a.cpus === b.cpus && a.memory_mib === b.memory_mib && (a.root_disk_mib ?? 0) === (b.root_disk_mib ?? 0) && (a.environment_disk_mib ?? 0) === (b.environment_disk_mib ?? 0); - const all = presets(provider, workspace); + const all = presets(provider); return all ? (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null : null; } @@ -102,12 +94,11 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources, worksp * which backend (own machines, microsandbox preselected) or the E2B account, * how big each sandbox is (only for a Provider that declares a default size: * E2B sandboxes take the template build's size), then a review. The Provider's - * declarations decide the size, disk and Runtime inputs. Advanced settings - * hold the complete form. The Runtime release comes from this console's - * distribution manifest when it serves one. + * declarations decide the size and disk inputs. Advanced settings hold the + * complete form. * `current` pre-selects the saved choices when a deployment changes. Keeping - * the backend keeps its saved size and Runtime; another backend starts from its - * defaults and this console's Runtime. E2B updates can retain the saved key. + * the backend keeps its saved size; another backend starts from its defaults. + * E2B updates can retain the saved key. * Docker isolates less than microsandbox, so choosing it takes a confirmation, * once per wizard session; a saved Docker deployment has already made it. * Core's address is config.json's `public_url`: the review only shows it, and @@ -118,25 +109,26 @@ type WizardProps = { expectedGeneration: number; current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string; e2bAPIURL?: string; e2bDomain?: string }; disabled: boolean; - /** A Runtime release an update proposes; the wizard opens on its review. */ - proposedRuntime?: SandboxRuntimeRelease; } & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise } | { editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise }); -export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, proposedRuntime, onSubmit }: WizardProps) { +export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, onSubmit }: WizardProps) { const { t, i18n } = useTranslation("sandbox"); const { t: tCommon } = useTranslation("common"); const id = useId(); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null; - const [step, setStep] = useState(proposedRuntime ? "review" : editing ? currentMode === "direct" ? "e2b" : "size" : "where"); + const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); const [where, setWhere] = useState(currentMode); const [provider, setProvider] = useState(current?.provider ?? null); const policy = provider ? deploymentContract.providers[provider] : null; const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; + // The selected storage sizes a first initialization; a saved specification takes precedence. + const storage = useQuery(workspaceStorageQuery).data; + const workspace = saved?.workspace ?? (provider ? selectedWorkspace(provider, storage) : undefined); // Choosing a Provider that declares a default size replaces this placeholder. const [resources, setResources] = useState(current?.specification?.resources ?? { cpus: 0, memory_mib: 0 }); - const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources, current.specification.workspace) ?? "current" : "standard"); + const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); const [apiKey, setApiKey] = useState(""); const [replacementRequested, setReplacementRequested] = useState(false); const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); @@ -151,7 +143,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [discoveryRetry, setDiscoveryRetry] = useState(0); - const [runtime, setRuntime] = useState>(proposedRuntime ?? {}); const [busy, setBusy] = useState(false); const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); const [confirmingDocker, setConfirmingDocker] = useState(false); @@ -195,35 +186,27 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); } - // A release the administrator entered or an update proposes comes first, then the saved one of the same backend, - // then the one this console distributes (the release its node installer verifies). - const matched = useQuery(distributionRuntimeQuery); - const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - - const needsRuntime = policy?.runtime ?? false; - const runtimeReady = !needsRuntime || isRuntimeRelease(release); // Initial setup requires a key; an update may retain the committed key. const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey); const connectionChanged = Boolean(editing && (apiURL.trim() !== (current?.e2bAPIURL || E2B_PRESETS.official.apiURL) || domain.trim() !== (current?.e2bDomain || E2B_PRESETS.official.domain))); const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim()) && validEndpoint(apiURL.trim(), domain.trim()) && (!editing || !connectionChanged || apiKey.trim().length > 0)); // A Provider without a declared default size takes it from its configuration, so the selection sends no resources. const sized = Boolean(policy?.default_resources); - const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources, saved?.workspace)); - const ready = provider !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; + const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources, workspace)); + const ready = provider !== null && e2bReady && sizeReady && !disabled && !busy; const order: Step[] = editing ? where === "direct" ? ["e2b", "review"] : ["size", "review"] : where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); const back = () => setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); - // The saved backend keeps its size and Runtime; another starts from its declared default size and this console's Runtime. + // The saved backend keeps its size; another starts from its declared default size. function choose(next: SandboxProvider) { if (next !== provider) { setRejection(null); const kept = current ? savedSpecification(next, current.provider, current.specification) : null; const proposed = kept?.resources ?? defaultSandboxResources(next); if (proposed) setResources(proposed); - setSize(kept ? presetOf(next, kept.resources, kept.workspace) ?? "current" : "standard"); - setRuntime({}); + setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); } setProvider(next); } @@ -243,7 +226,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const selection = { provider, expected_generation: expectedGeneration, ...(sized ? { resources } : {}), - ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), }; if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { ...e2bUpdateSelection(template, apiKey), configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); @@ -255,7 +237,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setFieldRejection(error); if (error instanceof AgentCoreError && error.param) { if (["credential", "configuration", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("e2b"); - else if (error.param === "runtime" || error.param.startsWith("resources.")) setStep("advanced"); + else if (error.param.startsWith("resources.")) setStep("advanced"); } setResetRequired(error instanceof AgentCoreError && ["sandbox_credential_ownership", "sandbox_reset_required"].includes(error.code ?? "")); setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error"); @@ -266,7 +248,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab } const sizeLabel = (value: SandboxResources) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); - const diskLabel = (value: SandboxResources) => saved?.workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes((value.root_disk_mib ?? 0) * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes((value.root_disk_mib ?? 0) * MIB), data: formatBytes((value.environment_disk_mib ?? 0) * MIB) }); + const diskLabel = (value: SandboxResources) => t("Root disk {{root}} · external workspace", { root: formatBytes((value.root_disk_mib ?? 0) * MIB) }); let page: ReactNode; if (step === "where") { @@ -280,7 +262,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ); } else if (step === "backend") { page = ( - +
{ choose("microsandbox"); setStep("size"); }} /> { if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} /> @@ -344,9 +326,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ); } else if (step === "size") { - const options = provider ? presets(provider, saved?.workspace) : null; + const options = provider ? presets(provider) : null; // A saved size outside the presets stays on offer as the current one. - const kept = saved && provider && presetOf(provider, saved.resources, saved.workspace) === null ? saved.resources : null; + const kept = saved && provider && presetOf(provider, saved.resources) === null ? saved.resources : null; const disks = (value: SandboxResources) => (policy?.disk ? diskLabel(value) : undefined); page = ( @@ -363,6 +345,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab /> ))}
+ {policy && "workspace" in policy && !workspace ?

{t("This backend runs every sandbox on workspace storage. Select it under Settings, Workspace storage first.")}

: null} @@ -379,12 +362,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {provider === "e2b" ?
{t("Sandbox API URL")}
{apiURL || "https://api.e2b.app"}
: null} {provider === "e2b" ?
{t("Sandbox data-plane domain")}
{domain || "e2b.app"}
: null} {provider === "e2b" && editing ?
{t("E2B credential")}
{t(replacementRequested ? "Replace saved key" : "Keep saved key")}
: null} - {needsRuntime ? ( -
-
{t("Runtime")}{t("The target Runtime release. Existing sandboxes keep their owned release while nodes prepare the target.")}
-
{runtimeReady ? {release.source_commit!.slice(0, 12)} : {t("Runtime release needed")}{t("This console serves no Runtime manifest. Enter the release under advanced settings.")}}
-
- ) : null}
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
@@ -431,24 +408,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> {policy?.disk ? <> { setResources({ ...resources, root_disk_mib }); setFieldRejection(null); }} /> - {saved?.workspace && !saved.workspace.capacity_quota ?

{t("Workspace storage is configured by the operator; no capacity quota is enforced.")}

: { setResources({ ...resources, environment_disk_mib }); setFieldRejection(null); }} />} + {workspace?.capacity_quota ? { setResources({ ...resources, environment_disk_mib }); setFieldRejection(null); }} /> : workspace ?

{t("Workspace storage is configured by the operator; no capacity quota is enforced.")}

: null} : null}
: null} - {needsRuntime ? ( -
- {t("Runtime release")}{t("Filled in from this console's distribution when it serves one. Otherwise copy these from the distribution manifest that matches your nodes; image configuration IDs and manifest digests are different values.")} - {fieldError("runtime") ? : null} - {RUNTIME_RELEASE_FIELDS.map((field) => { - const value = release[field] ?? ""; - return ( - - { setRuntime({ ...release, [field]: event.target.value.trim() }); setFieldRejection(null); }} /> - - ); - })} -
- ) : null} {provider === "e2b" ? ( { setTemplate(event.target.value); setFieldRejection(null); }} autoComplete="off" spellCheck={false} /> diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index a55925899..0c9e1cf26 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,25 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSandboxResources, distributionRuntime, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { describe, expect, it } from "vitest"; +import { defaultSandboxResources, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification"; import { deploymentContract, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client"; -const manifest = { platform: "linux/amd64", source_commit: "0".repeat(40), images: { runtime: `sha256:${"a".repeat(64)}` }, - image_manifest_digests: { runtime: `sha256:${"b".repeat(64)}` }, runtime_ref: `oac-runtime@sha256:${"b".repeat(64)}`, - microsandbox: { runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) } }; -afterEach(() => vi.unstubAllGlobals()); - -describe("deployment resources and Runtime", () => { - it("maps the exact six identities from one matched distribution", async () => { - const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(manifest))); - vi.stubGlobal("fetch", fetcher); - expect(await distributionRuntime(new AbortController().signal)).toEqual({ source_commit: manifest.source_commit, - image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime, - microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 }); - expect(fetcher.mock.calls.map((call) => call[0])).toEqual(["/node-install/manifest.json"]); - }); - it("preserves saved resources and Runtime only for the same provider", () => { - const current: SandboxSpecification = { resources: { cpus: 7, memory_mib: 8192 }, runtime: { source_commit: manifest.source_commit, - image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime, - microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 } }; +describe("deployment resources", () => { + it("preserves saved resources only for the same provider", () => { + const current: SandboxSpecification = { resources: { cpus: 7, memory_mib: 8192 } }; expect(savedSpecification("docker", "docker", current)).toEqual(current); expect(savedSpecification("docker", "docker", current)).not.toBe(current); expect(savedSpecification("microsandbox", "docker", current)).toBeNull(); @@ -33,7 +18,7 @@ describe("deployment resources and Runtime", () => { expect(resources).toEqual(declared); if (resources) { expect(resources).not.toBe(declared); - expect(validSandboxResources(provider, resources)).toBe(true); + expect(validSandboxResources(provider, resources, selectedWorkspace(provider, { tenant_capacity_mib: 0 }))).toBe(true); } } }); @@ -43,12 +28,12 @@ describe("deployment resources and Runtime", () => { expect(validSandboxResources("docker", { cpus: 2, memory_mib: 511 })).toBe(false); expect(validSandboxResources("docker", { cpus: 2, memory_mib: 1048577 })).toBe(false); expect(validSandboxResources("e2b", { cpus: 2, memory_mib: 2048, root_disk_mib: 1024 })).toBe(false); - expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023, environment_disk_mib: 8192 })).toBe(false); + expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023 }, { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false })).toBe(false); }); - it("validates external storage without fabricating a disk quota or weakening root disk bounds", () => { + it("runs microsandbox only on selected storage, where the environment disk is a quota request", () => { const workspace = { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false }; - const resources = defaultSandboxResources("microsandbox", workspace)!; - expect(resources.environment_disk_mib).toBe(0); + const resources = defaultSandboxResources("microsandbox")!; + expect(resources.environment_disk_mib).toBeUndefined(); expect(validSandboxResources("microsandbox", resources, workspace)).toBe(true); expect(validSandboxResources("microsandbox", resources)).toBe(false); expect(validSandboxResources("microsandbox", { ...resources, root_disk_mib: 0 }, workspace)).toBe(false); @@ -57,34 +42,10 @@ describe("deployment resources and Runtime", () => { expect(validSandboxResources("docker", { cpus: 2, memory_mib: 2048 }, workspace)).toBe(false); expect(validSandboxResources("microsandbox", { ...resources, environment_disk_mib: 8192 }, { ...workspace, capacity_quota: true })).toBe(true); }); - it("accepts only Core's Runtime image in the Runtime reference", async () => { - const digest = "b".repeat(64); - const runtime_ref = `oac-runtime@sha256:${digest}`; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - expect((await distributionRuntime(new AbortController().signal)).microsandbox_ref).toBe(runtime_ref); - expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(true); - for (const runtime_ref of [`custom-runtime@sha256:${digest}`, `oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`]) { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); - expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(false); - } - }); - it("rejects unavailable, mutable or incomplete release identities", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 404 }))); - await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); - for (const invalid of [{ ...manifest, platform: "linux/arm64" }, { ...manifest, source_commit: "main" }, { ...manifest, runtime_ref: "oac-runtime:latest" }, { ...manifest, microsandbox: {} }]) { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(invalid)))); - await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); - } - }); -}); - -describe("sandboxes a host holds", () => { - it("is the smaller of what its CPUs and its memory hold, and unknown without either or the size", () => { - const size = { cpus: 2, memory_mib: 4096 }; - expect(sandboxesThatFit({ cpus: 16, memoryBytes: 64 * 2 ** 30 }, size)).toBe(8); - expect(sandboxesThatFit({ cpus: 64, memoryBytes: 18 * 2 ** 30 }, size)).toBe(4); - expect(sandboxesThatFit({ cpus: null, memoryBytes: 64 * 2 ** 30 }, size)).toBeNull(); - expect(sandboxesThatFit({ cpus: 16, memoryBytes: 64 * 2 ** 30 }, null)).toBeNull(); + it("takes the workspace requirement from the selected storage", () => { + expect(selectedWorkspace("microsandbox", { tenant_capacity_mib: 0 })).toMatchObject({ capacity_quota: false }); + expect(selectedWorkspace("microsandbox", { tenant_capacity_mib: 1024 })).toMatchObject({ capacity_quota: true }); + expect(selectedWorkspace("microsandbox", null)).toBeUndefined(); + expect(selectedWorkspace("docker", { tenant_capacity_mib: 0 })).toBeUndefined(); }); }); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 2beb67bb4..5e6cfc596 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,45 +1,29 @@ -import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; - -interface Manifest { - platform?: string; - source_commit?: string; - images?: { runtime?: string }; - image_manifest_digests?: { runtime?: string }; - runtime_ref?: string; - microsandbox?: { runtime_sha256?: string; firmware_sha256?: string }; -} +import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; /** The size the Provider declares for setup to propose; null when its configuration selects the size. */ -export function defaultSandboxResources(provider: SandboxProvider, workspace?: SandboxSpecification["workspace"]): SandboxResources | null { +export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { const size: SandboxResources | null = deploymentContract.providers[provider].default_resources; - return size && { ...size, ...(workspace ? { environment_disk_mib: 0 } : {}) }; + return size && { ...size }; +} + +/** The workspace requirement a Provider takes from the selected storage; none when it owns its workspaces or no storage is selected. */ +export function selectedWorkspace(provider: SandboxProvider, storage: { tenant_capacity_mib: number } | null | undefined): SandboxSpecification["workspace"] { + const policy = deploymentContract.providers[provider]; + return storage && "workspace" in policy ? { ...policy.workspace, capacity_quota: storage.tenant_capacity_mib > 0 } : undefined; } -/** Core keeps owned disk limits; external workspace capacity is optional and requires real quota support. */ +/** A Provider that declares a workspace policy runs every Environment on the selected storage; its environment disk is only a quota request. */ export function validSandboxResources(provider: SandboxProvider, resources: SandboxResources, workspace?: SandboxSpecification["workspace"]): boolean { const policy = deploymentContract.providers[provider]; - if (workspace) { - if (!("workspace" in policy) || workspace.attachment !== policy.workspace.attachment || (policy.workspace.user_xattr && !workspace.user_xattr) || ((resources.environment_disk_mib ?? 0) > 0 && !workspace.capacity_quota)) return false; - } + if ("workspace" in policy !== Boolean(workspace)) return false; + if (workspace && "workspace" in policy && (workspace.attachment !== policy.workspace.attachment || (policy.workspace.user_xattr && !workspace.user_xattr))) return false; return deploymentContract.resources.every((rule) => { - const [min, max] = workspace && rule.name === "environment_disk_mib" && (resources.environment_disk_mib ?? 0) === 0 ? [0, 0] : !rule.omit_zero ? [rule.min, rule.max] : deploymentContract.providers[provider].disk ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; const value = resources[rule.name] ?? 0; + const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : rule.name === "environment_disk_mib" && workspace?.capacity_quota ? [value === 0 ? 0 : deploymentContract.minimum_disk, rule.max] : rule.name === "root_disk_mib" && policy.disk ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; return Number.isInteger(value) && value >= min && value <= max; }); } -const releasePatterns = Object.fromEntries(deploymentContract.runtime.map(({ name, pattern }) => [name, new RegExp(`^(?:${pattern})$`)])) as Record; - -export const RUNTIME_RELEASE_FIELDS = deploymentContract.runtime.map(({ name }) => name); - -export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean { - return releasePatterns[field].test(value); -} - -export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease { - return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && releasePatterns[field].test(value[field])); -} - export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { return provider === savedProvider && specification ? structuredClone(specification) : null; } @@ -60,24 +44,3 @@ export function templateBuildStatus(build: SandboxE2BTemplateBuild | undefined): export function sandboxSize(deployment: SandboxDeployment): SandboxResources | null { return deployment.specification?.resources ?? templateBuildSize(deployment); } - -/** - * At most how many sandboxes of this size a host's CPUs and memory hold at once, - * each at its full limits; null while a figure or the size is unknown. A - * suggestion for a node's limit, which Core itself never derives. - */ -export function sandboxesThatFit(host: { cpus: number | null; memoryBytes: number | null }, size: Pick | null): number | null { - if (!size || host.cpus === null || host.memoryBytes === null || size.cpus <= 0 || size.memory_mib <= 0) return null; - return Math.min(Math.floor(host.cpus / size.cpus), Math.floor(host.memoryBytes / (size.memory_mib * 2 ** 20))); -} - -/** The paired console serves one matched distribution; Core persists approval. */ -export async function distributionRuntime(signal: AbortSignal): Promise { - const response = await fetch("/node-install/manifest.json", { signal, credentials: "include", redirect: "error" }); - if (!response.ok) throw new Error("distribution unavailable"); - const manifest = await response.json() as Manifest; - const release = { source_commit: manifest.source_commit, image_id: manifest.images?.runtime, image_manifest_digest: manifest.image_manifest_digests?.runtime, - microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox?.runtime_sha256, firmware_sha256: manifest.microsandbox?.firmware_sha256 }; - if (manifest.platform !== "linux/amd64" || !isRuntimeRelease(release)) throw new Error("invalid distribution"); - return release; -} diff --git a/apps/web/src/features/sandbox/node-enrollment.test.ts b/apps/web/src/features/sandbox/node-enrollment.test.ts index 74025f841..91d6273a9 100644 --- a/apps/web/src/features/sandbox/node-enrollment.test.ts +++ b/apps/web/src/features/sandbox/node-enrollment.test.ts @@ -31,7 +31,6 @@ describe("node enrollment", () => { const connected = { ...fresh, online: true }; expect(enrollmentProgress(connected, 0, 1_000)).toEqual({ stage: "connected", problem: "" }); expect(enrollmentProgress(connected, 0, NODE_READY_WAIT_MS)).toEqual({ stage: "connected", problem: "provider_unavailable" }); - expect(enrollmentProgress({ ...connected, diagnostic: "host_unsupported" }, 0, 1_000)).toEqual({ stage: "connected", problem: "host_unsupported" }); expect(enrollmentProgress({ ...connected, provider_ready: true }, 0, NODE_READY_WAIT_MS * 2)).toEqual({ stage: "ready", problem: "" }); }); diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 93328f795..067a41b9f 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -95,5 +95,5 @@ export function enrollmentProgress(node: SandboxNode | null, appearedAt: number if (node.online && node.provider_ready) return { stage: "ready", problem: "" }; const late = appearedAt !== undefined && now - appearedAt >= NODE_READY_WAIT_MS; if (!node.online) return { stage: "registered", problem: late ? "not_connected" : "" }; - return { stage: "connected", problem: node.diagnostic || late ? nodeProviderDiagnostic(node) : "" }; + return { stage: "connected", problem: late ? nodeProviderDiagnostic(node) : "" }; } diff --git a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts index 552b4f541..be04e2036 100644 --- a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts +++ b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts @@ -5,7 +5,7 @@ import { sandboxAdmin, sandboxDeploymentQuery } from "./sandbox-queries"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; -const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", owner_epoch: 1, generation: 1, provider: "docker", core_url: "http://core", mode: "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null }; +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", owner_epoch: 1, generation: 1, provider: "docker", core_url: "http://core", mode: "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null }; function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 643c2fab4..62bef4afb 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -9,7 +9,7 @@ const reset: SandboxReset = { remaining: { busy: 1, idle: 0, cleanup: 1, on_offline_nodes: 1, offline_nodes: [{ node_id: "node-a", name: "Offline host", resources: 1 }] }, }; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, - rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "installation-a", provider: "docker", core_url: "https://core.example", reset, + rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "installation-a", provider: "docker", core_url: "https://core.example", reset, owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, pending: 0 }, suspension: null, ...overrides, }); const clients: QueryClient[] = []; @@ -18,7 +18,7 @@ afterEach(() => { for (const client of clients.splice(0)) client.clear(); vi.res describe("sandbox reset reads", () => { it("polls only Core preparation or reset, never settled old resources or failed/unknown nodes", () => { - const settled = deployment({ reset: null, rollout: { state: "settled", previous_generation_sandboxes: 9, nodes: { ready: 0, preparing: 0, failed: 2, update_required: 1, unknown: 3 } } }); + const settled = deployment({ reset: null, rollout: { state: "settled", previous_generation_sandboxes: 9, nodes: { ready: 0, preparing: 0, failed: 2, unknown: 3 } } }); expect(sandboxResetPollInterval(settled)).toBe(false); expect(sandboxResetPollInterval({ ...settled, rollout: { ...settled.rollout, state: "preparing" } })).toBe(5000); expect(sandboxResetPollInterval({ ...settled, reset })).toBe(5000); diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 5bf2fe68d..81029f64e 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -3,7 +3,6 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { confirmSandboxRead, startSandboxRead } from "./sandbox-write-ownership"; import { sandboxConsoleConfig } from "./console-config"; -import { distributionRuntime } from "./deployment-specification"; /** * Cache entries for the sandbox deployment (`/core/v1/sandbox`). Every read @@ -25,14 +24,6 @@ export const sandboxConsoleConfigQuery = queryOptions({ }, }); -/** The Runtime release of this console's distribution manifest, the release its node installer verifies; null when it serves none. */ -export const distributionRuntimeQuery = queryOptions({ - queryKey: ["sandbox-runtime-release"], - queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), - staleTime: Infinity, - retry: false, -}); - /** The deployment alone, for pages that only describe it. */ export const sandboxDeploymentQuery = queryOptions({ queryKey: [...sandboxScope, "deployment"], diff --git a/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx index 8ce16999d..63c8667c2 100644 --- a/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx +++ b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx @@ -6,7 +6,7 @@ import { node } from "../overview/test-fixtures"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; -const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; const resetting: SandboxDeployment = { ...configured, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; const clients: QueryClient[] = []; function cache() { diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index b668a1599..1f46ea59c 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type ExecutorCredential, type IssuedExecutorCredential } from "@oac/agents-client"; +import { AgentCoreError, randomUuid, type ExecutorCredential, type IssuedExecutorCredential } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useEffect, useState } from "react"; @@ -24,16 +24,6 @@ import { ExecutorConnectionPanel } from "./ExecutorConnectionPanel"; /** An issuance that gets no answer in this time has an unknown outcome. */ const WRITE_TIMEOUT_MS = 30_000; -/** A key ID for a new credential. Plain-HTTP consoles lack `crypto.randomUUID`, so it falls back to a v4 UUID. */ -function newKeyId(): string { - if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); - const bytes = crypto.getRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6]! & 0x0f) | 0x40; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} - /** * A write either failed before Core acted ("rejected", with Core's error) or * has an unknown outcome: no answer, a timeout, a 5xx or an unreadable reply. @@ -135,7 +125,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId const issue = async () => { if (busy) return; // A listed key ID is managed from its row; reusing it would recover a credential that was since rotated or revoked. - const keyId = kept !== null && !credentials?.some((credential) => sameKey(credential.key_id, kept)) ? kept : newKeyId(); + const keyId = kept !== null && !credentials?.some((credential) => sameKey(credential.key_id, kept)) ? kept : randomUuid(); setKept(keyId); setIssuing(true); try { diff --git a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx index 78c55c1af..a0146d3c2 100644 --- a/apps/web/src/features/settings/WorkspaceStorageSettings.tsx +++ b/apps/web/src/features/settings/WorkspaceStorageSettings.tsx @@ -1,4 +1,4 @@ -import { type WorkspaceStorageSelection } from "@oac/agents-client"; +import { randomUuid, type WorkspaceStorageSelection } from "@oac/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -21,7 +21,7 @@ function StorageEditor({ current, replacement, onClose }: { current: WorkspaceSt const formId = useId(); // Retain this identity across failed or uncertain saves. Credentials never enter the query cache. const [selection] = useState(current); - const [configurationId] = useState(() => replacement ? crypto.randomUUID() : selection?.id ?? ""); + const [configurationId] = useState(() => replacement ? randomUuid() : selection?.id ?? ""); const selectionChanged = !replacement && (!selection || current?.id !== selection.id); const [adapter, setAdapter] = useState(current?.adapter ?? ""); const [parameters, setParameters] = useState(JSON.stringify(current?.parameters ?? {}, null, 2)); @@ -61,7 +61,7 @@ function StorageEditor({ current, replacement, onClose }: { current: WorkspaceSt
{ event.preventDefault(); void save(); }}> {replacement ? <>

{t("newSelection")}

- {t("instructions")} + {t("instructions")}
{t("adapterHelp")}
setAdapter(event.target.value)} required disabled={busy} />
{t("parametersHelp")}