diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0ca024318..cfe483d09 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,6 +10,11 @@ name: core-deploy on: workflow_dispatch: inputs: + build_only: + description: Build and push images without changing the production installation + required: false + default: false + type: boolean source_branch: description: Source branch deployed to the existing production installation required: true @@ -61,7 +66,7 @@ jobs: exit 1 fi echo "revision=$revision" >> "$GITHUB_OUTPUT" - echo "Deploying $SOURCE_BRANCH at \`$revision\`" >> "$GITHUB_STEP_SUMMARY" + echo "Selected $SOURCE_BRANCH at \`$revision\`" >> "$GITHUB_STEP_SUMMARY" build: needs: prepare @@ -175,6 +180,7 @@ jobs: run: docker logout "${REGISTRY_HOST%%/*}" || true deploy: + if: ${{ !inputs.build_only }} needs: [prepare, build] runs-on: ${{ vars.OAC_DEPLOY_RUNNER || 'ubuntu-22.04' }} environment: production diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index b5af01e8c..d90a069dc 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -13,7 +13,6 @@ on: - docs.json - package.json - pnpm-workspace.yaml - - .npmrc - Makefile - .github/actions/node/** - .github/workflows/website.yml diff --git a/.gitignore b/.gitignore index 3d22625f0..40355526c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,6 @@ coverage/ go.work.sum __pycache__/ *.pyc -/config/ -/logs/ -/state/ -/cache/ -/target/ # `go build ./services/web` in the repository root writes this binary. /web playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index 6bc0a950d..0075923ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ 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) | +| Nodes and daemons–Core (`/api/v1` HTTP routes; the node and daemon wire protocols are separate rows) | `internal/machineapi/machineapi.go` (wire types) plus 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 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) | diff --git a/Makefile b/Makefile index 2e73eade1..3efed26be 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-codex-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-codex-runtime build-claude-runtime build-claude-sdk-runtime 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.' @@ -38,7 +38,7 @@ openapi: output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --swag-roots "$$output/roots.go"; \ $(SWAG) init \ - -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,./internal/modelprovider,$$output" \ + -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,./internal/modelprovider,./internal/machineapi,$$output" \ --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ @@ -154,12 +154,6 @@ build-codex-runtime: build-claude-runtime: ./scripts/build-claude-runtime.sh -build-mcode-harness: - ./scripts/build-mcode-harness.sh - -build-mcode-runtime: - ./scripts/build-mcode-runtime.sh - .PHONY: build-microsandbox-provider check-microsandbox-provider build-microsandbox-provider: python3 scripts/build-microsandbox-provider.py build "$${OAC_DEV_HOME:-$$HOME/.oac}/build/microsandbox-provider/oac-microsandbox-provider" @@ -180,7 +174,6 @@ check-distribution: go test ./services/web ./services/core/cmd/oac -count=1 PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/node -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/compose -p 'test_*.py' - PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/acceptance -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 deploy/test_install.py PYTHONDONTWRITEBYTECODE=1 python3 deploy/test_install_source.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py diff --git a/README.ja.md b/README.ja.md deleted file mode 100644 index e12d93c55..000000000 --- a/README.ja.md +++ /dev/null @@ -1,87 +0,0 @@ -
- - - - OpenAgentCore - - -オープンソースでセルフホストできる OpenAI Agents API。公式 OpenAI SDK から、自分のマシンと自分のモデルで Codex、Claude Code、MiniMax Code を実行できます。 - -[![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/) · [ドキュメント](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)を通じて接続されます。 - -![デプロイの概要](docs/assets/console-overview-en.webp) - -## クイックスタート - -[前提条件](https://openagentcore.dev/docs/getting-started/install#prerequisites)に従って Docker をセットアップした Linux または macOS で、Core、Web、PostgreSQL をインストールします。 - -```sh -curl -fsSL https://openagentcore.dev/install.sh | bash -``` - -Windows では PowerShell で実行します。 - -```powershell -irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex -``` - -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 を設定してください。 - -```python -from openai import OpenAI - -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) -``` - -[最初の Session を実行](https://openagentcore.dev/docs/getting-started/quickstart)では、結果を待って読み戻します。 - -## 仕組み - - - - OpenAgentCore のアーキテクチャ - - -アプリケーションと管理者は、次の 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)をご覧ください。 - -## ドキュメント - -| 目的 | 参照先 | -| --- | --- | -| インストールと運用 | [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 などのコンポーネントの追加 | [OpenAgentCore の開発](https://openagentcore.dev/docs/development) | - -## コントリビューションとライセンス - -コードを変更する前に [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 83ef3704b..8601923f3 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ The OpenAI Agents API, open source and self-hosted. Run Codex, Claude Code or Mi [Website](https://openagentcore.dev/) · [Documentation](https://openagentcore.dev/docs/getting-started/) · [Quickstart](#quickstart) · [Contributing](CONTRIBUTING.md) -**English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) +**English** · [简体中文](README.zh-CN.md) - **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). +- **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 pauses idle sandboxes in place. 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). ![Deployment overview](docs/assets/console-overview-en.webp) diff --git a/README.zh-CN.md b/README.zh-CN.md index 535f2252e..a92ff4679 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,13 +11,13 @@ [官网](https://openagentcore.dev/zh/) · [文档](https://openagentcore.dev/zh/docs/getting-started/) · [快速开始](#快速开始) · [参与贡献](CONTRIBUTING.md) -[English](README.md) · **简体中文** · [日本語](README.ja.md) +[English](README.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)。 +- **计算会休眠,工作区会保留。** 在 microsandbox 上(每个工作区都保存在独立存储中),空闲沙箱释放计算资源,并在兼容节点上带着文件恢复。E2B 会就地暂停空闲沙箱。Docker 以及你自己的 Linux、macOS 或 Windows 机器同样可以运行 Session。详见[沙箱提供商协议](https://openagentcore.dev/zh/docs/sandbox-provider)。 - **部件可替换。** 沙箱提供商、工作区文件系统、Harness 和模型提供商都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。 ![部署概览](docs/assets/console-overview-zh.webp) diff --git a/apps/daemon/internal/agent/binpath/binpath.go b/apps/daemon/internal/agent/binpath/binpath.go deleted file mode 100644 index 792640034..000000000 --- a/apps/daemon/internal/agent/binpath/binpath.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package binpath resolves which executable each agent adapter should -// probe and spawn. -// -// By default every engine is looked up by bare name on PATH ("codex", -// "codex", ...). That breaks in images where PATH is not under our -// control: e2b's base image, for instance, ships its own -// /usr/local/bin entries that can shadow the ones we install, and a -// bare-name lookup then resolves to the wrong (or no) binary. The -// symptom is the worst kind — `oac-daemon connect` reports -// "no supported agent CLI available" and the device never dials in, -// with no indication of which lookup failed. -// -// The env overrides below let an image or operator pin an absolute path -// instead. They are read in ONE place so the version probe -// (CheckCLIAvailable) and the run-time spawn (sessionConfig) can never -// disagree: a probe that succeeds against /custom/codex while the run -// spawns PATH's `codex` would advertise a capability the daemon cannot -// actually honour. -package binpath - -import ( - "os" - "strings" -) - -// Env var names for the per-engine executable overrides. Empty or unset -// means "look up the default name on PATH". -const ( - EnvCodex = "OAC_RUNTIME_CODEX_BIN" - EnvMCode = "OAC_RUNTIME_MCODE_BIN" -) - -// Default executable names, used when the matching env var is unset. -const ( - DefaultCodex = "codex" - DefaultMCode = "mcode" -) - -// resolve returns the trimmed env override when set, else fallback. -func resolve(envVar, fallback string) string { - if v := strings.TrimSpace(os.Getenv(envVar)); v != "" { - return v - } - return fallback -} - -// Codex returns the codex executable to probe and spawn. -func Codex() string { return resolve(EnvCodex, DefaultCodex) } - -// MCode returns the mcode executable to probe and spawn. -func MCode() string { return resolve(EnvMCode, DefaultMCode) } diff --git a/apps/daemon/internal/agent/binpath/binpath_test.go b/apps/daemon/internal/agent/binpath/binpath_test.go deleted file mode 100644 index be72eaef4..000000000 --- a/apps/daemon/internal/agent/binpath/binpath_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package binpath - -import "testing" - -func TestResolvers(t *testing.T) { - tests := []struct { - name string - envVar string - fallback string - resolve func() string - override string - wantPinned string - }{ - {name: "codex", envVar: EnvCodex, fallback: DefaultCodex, resolve: Codex, override: " /opt/agents/codex ", wantPinned: "/opt/agents/codex"}, - {name: "mcode", envVar: EnvMCode, fallback: DefaultMCode, resolve: MCode, override: " /opt/agents/mcode ", wantPinned: "/opt/agents/mcode"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Setenv(tt.envVar, "") - if got := tt.resolve(); got != tt.fallback { - t.Fatalf("unset override: got %q, want %q", got, tt.fallback) - } - - t.Setenv(tt.envVar, tt.override) - if got := tt.resolve(); got != tt.wantPinned { - t.Fatalf("explicit override: got %q, want %q", got, tt.wantPinned) - } - - t.Setenv(tt.envVar, " \t ") - if got := tt.resolve(); got != tt.fallback { - t.Fatalf("blank override: got %q, want %q", got, tt.fallback) - } - }) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/bridge_frames_test.go b/apps/daemon/internal/agent/claudesdk/bridge_frames_test.go new file mode 100644 index 000000000..fa2126d2c --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/bridge_frames_test.go @@ -0,0 +1,106 @@ +//go:build unix + +package claudesdk + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// The TypeScript bridge reads the same file in packages/claude-sdk-adapter/tests/bridge_frames.test.mjs. +func TestBridgeFramesMatchSharedFixtures(t *testing.T) { + data, err := os.ReadFile("../../../../../packages/claude-sdk-adapter/fixtures/bridge-frames.json") + if err != nil { + t.Fatal(err) + } + var fixtures struct { + ToBridge []json.RawMessage `json:"to_bridge"` + FromBridge []json.RawMessage `json:"from_bridge"` + } + if err := json.Unmarshal(data, &fixtures); err != nil { + t.Fatal(err) + } + strict := func(raw json.RawMessage, into any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + return decoder.Decode(into) + } + for _, raw := range fixtures.ToBridge { + var kind struct{ Type string } + if err := json.Unmarshal(raw, &kind); err != nil { + t.Fatal(err) + } + var frame any + switch kind.Type { + case "executor_prepare": + frame = &startRequest{} + case "turn_start": + frame = &turnStartFrame{} + case "turn_cancel": + frame = &turnCancelFrame{} + case "steer": + frame = &steerFrame{} + case "function_result": + frame = &functionResultFrame{} + default: + t.Fatalf("fixture frame %q has no Go producer", kind.Type) + } + if err := strict(raw, frame); err != nil { + t.Fatalf("%s: %v", kind.Type, err) + } + // Encoding must reproduce every fixture field, so no field is dropped or renamed. + encoded, err := json.Marshal(frame) + if err != nil { + t.Fatal(err) + } + var want, got any + if json.Unmarshal(raw, &want) != nil || json.Unmarshal(encoded, &got) != nil { + t.Fatal("invalid JSON") + } + if !bytes.Equal(canonical(t, want), canonical(t, got)) { + t.Errorf("%s: Go encodes\n%s\nfixture\n%s", kind.Type, encoded, raw) + } + } + for _, raw := range fixtures.FromBridge { + var event bridgeEvent + if err := strict(raw, &event); err != nil { + t.Errorf("%s: %v", raw, err) + continue + } + if event.Type == "executor_ready" { + continue + } + // Whatever else the Turn makes of the frame, it must know the kind. + config, req := persistentConfig(t, "replay") + compact := new(bytes.Buffer) + if err := json.Compact(compact, raw); err != nil { + t.Fatal(err) + } + config.Env = append(config.Env, "SDK_REPLAY_FRAME="+compact.String()) + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + _, out := consumeExecutorTurn(t, owner, "run", "hello") + for envelope := range out { + var failure proto.ErrorPayload + if envelope.Type == proto.TypeError && envelope.DecodePayload(&failure) == nil && failure.Error == "claudesdk: unknown SDK bridge event" { + t.Errorf("Go does not handle bridge frame kind %q", event.Type) + } + } + _ = owner.Close(t.Context()) + } +} + +func canonical(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/apps/daemon/internal/agent/claudesdk/bridge_output.go b/apps/daemon/internal/agent/claudesdk/bridge_output.go deleted file mode 100644 index 6101240af..000000000 --- a/apps/daemon/internal/agent/claudesdk/bridge_output.go +++ /dev/null @@ -1,32 +0,0 @@ -package claudesdk - -import "bufio" - -type bridgeOutput struct { - frames chan []byte - current []byte - err error -} - -func (s *session) bridgeOutput() *bridgeOutput { - output := &bridgeOutput{frames: make(chan []byte, 1)} - go func() { - defer close(output.frames) - scanner := bufio.NewScanner(s.process.Stdout) - scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) - for scanner.Scan() { - raw := append([]byte(nil), scanner.Bytes()...) - if !s.receiveWorkspaceRead(raw) && !s.receiveWorkspaceDirectory(raw) { - output.frames <- raw - } - } - output.err = scanner.Err() - if output.err != nil { - s.process.Cancel() - } - }() - return output -} -func (o *bridgeOutput) Scan() bool { var ok bool; o.current, ok = <-o.frames; return ok } -func (o *bridgeOutput) Bytes() []byte { return o.current } -func (o *bridgeOutput) Err() error { return o.err } diff --git a/apps/daemon/internal/agent/claudesdk/cancellation.go b/apps/daemon/internal/agent/claudesdk/cancellation.go index 01d18b381..e3070a246 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation.go @@ -32,10 +32,7 @@ func (s *session) Cancel(ctx context.Context) error { if current { stopWrite := context.AfterFunc(ctx, s.invalidate) defer stopWrite() - if err := s.owner.write(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - }{"turn_cancel", s.runID}); err != nil { + if err := s.owner.write(turnCancelFrame{"turn_cancel", s.runID}); err != nil { s.invalidate() } } diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go deleted file mode 100644 index 157133232..000000000 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -func TestLiveClaudeSDKCancelResume(t *testing.T) { - entrypoint := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT") - keyFile := os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") - if entrypoint == "" || keyFile == "" { - t.Skip("real cancellation acceptance requires explicit SDK entrypoint and private key file") - } - proofRoot := os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") - if !filepath.IsAbs(proofRoot) { - t.Fatal("OAC_TEST_CLAUDE_SDK_PROOF_DIR must be an absolute managed directory") - } - root, err := os.MkdirTemp(proofRoot, "claude-cancel-") - if err != nil { - t.Fatal(err) - } - t.Logf("real cancellation evidence: %s", root) - t.Setenv("OAC_RUNTIME_HOME", root) - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal(err) - } - config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ - "ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), - "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", - "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3", - }} - readiness, err := CheckRuntime(context.Background(), config) - if err != nil { - t.Fatal("real runtime readiness failed", err) - } - readinessJSON, _ := json.MarshalIndent(readiness, "", " ") - if err := os.WriteFile(filepath.Join(root, "readiness.json"), readinessJSON, 0o600); err != nil { - t.Fatal(err) - } - type evidence struct { - NodePID int `json:"node_pid"` - NativePIDs []int `json:"native_pids"` - CancelMS int64 `json:"cancel_milliseconds,omitempty"` - Cancelled bool `json:"cancelled"` - Failure string `json:"failure,omitempty"` - Outcome proto.DonePayload `json:"outcome"` - Events []proto.Envelope `json:"events"` - } - run := func(prompt, resume string, cancelOnText bool) evidence { - t.Helper() - 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, 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) - } - s := running.(*session) - defer s.Cancel(ctx) - proof := evidence{NodePID: s.process.Cmd.Process.Pid} - done := false - for event := range out { - proof.Events = append(proof.Events, event) - if event.Type == proto.TypeDelta && len(proof.NativePIDs) == 0 { - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", proof.NodePID, proof.NodePID)) - for _, value := range strings.Fields(string(raw)) { - pid, _ := strconv.Atoi(value) - args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { - proof.NativePIDs = append(proof.NativePIDs, pid) - } - } - } - switch event.Type { - case proto.TypeDelta: - if cancelOnText && !proof.Cancelled { - select { - case <-s.process.Done(): - t.Fatal("native execution ended before cancellation") - default: - } - started := time.Now() - if err := s.Cancel(ctx); err != nil { - t.Fatal("live cancellation failed", err) - } - proof.CancelMS = time.Since(started).Milliseconds() - proof.Cancelled = true - proof.Outcome = s.CancellationOutcome() - } - case proto.TypeError: - var payload proto.ErrorPayload - _ = event.DecodePayload(&payload) - proof.Failure = payload.Error - case proto.TypeDone: - done = true - var payload proto.DonePayload - if err := event.DecodePayload(&payload); err != nil { - t.Fatal(err) - } - if proof.Cancelled { - expected, _ := json.Marshal(proof.Outcome) - actual, _ := json.Marshal(payload) - if !bytes.Equal(expected, actual) { - t.Fatal("live cancellation outcome differs from Done") - } - } - proof.Outcome = payload - } - } - data, _ := json.MarshalIndent(proof, "", " ") - if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", proof.NodePID)), data, 0o600); err != nil { - t.Fatal(err) - } - if !done || len(proof.NativePIDs) == 0 || proof.Cancelled != cancelOnText { - t.Fatal("missing real execution/completion/cancellation evidence") - } - select { - case <-s.process.Done(): - default: - t.Fatal("completion preceded owned process release") - } - for _, pid := range append([]int{proof.NodePID}, proof.NativePIDs...) { - if value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) == 0 || fields[0] != "Z" { - t.Fatalf("execution process %d remains alive", pid) - } - } - } - return proof - } - nonce := "cancel-history-" + uuid.NewString() - first := run("Remember this exact verification value: "+nonce+". First repeat it, then write two hundred numbered sentences about trees. Do not use tools.", "", true) - id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || first.Outcome.Content == "" || first.Failure == "" { - t.Fatal("live cancellation lost identity, partial output or interruption evidence") - } - second := run("Return only the exact cancel-history verification value in the earlier user request. Ignore the earlier request for numbered sentences.", id, false) - if second.Failure != "" || second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(second.Outcome.Content, nonce) || first.NodePID == second.NodePID { - t.Fatalf("cold continuation did not preserve identity/history; evidence %s", root) - } - data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> maintained SDK/native -> real MiniMax cancellation and cold continuation; public admission remains separate", "verification_value": nonce, "executions": []evidence{first, second}}, "", " ") - if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go deleted file mode 100644 index 776fe8f17..000000000 --- a/apps/daemon/internal/agent/claudesdk/combination_live_linux_test.go +++ /dev/null @@ -1,140 +0,0 @@ -//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 939607cf9..e4689ae4a 100644 --- a/apps/daemon/internal/agent/claudesdk/contracts.go +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -5,15 +5,9 @@ import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Every public Harness implements each small contract explicitly. Unsupported // extensions return agent.ErrUnsupportedOperation without native effects. var ( - _ agent.Executor = (*executor)(nil) - _ agent.Turn = (*session)(nil) - _ agent.Session = (*session)(nil) - _ agent.DurableSteerer = (*session)(nil) - _ agent.FunctionResultSubmitter = (*session)(nil) - _ agent.WorkspaceReader = (*session)(nil) - _ agent.WorkspaceDirectoryLister = (*session)(nil) - _ agent.WorkspaceWriter = (*session)(nil) - _ agent.WorkspaceReader = (*executor)(nil) - _ agent.WorkspaceDirectoryLister = (*executor)(nil) - _ agent.WorkspaceWriter = (*executor)(nil) + _ agent.Executor = (*executor)(nil) + _ agent.Turn = (*session)(nil) + _ agent.Session = (*session)(nil) + _ agent.DurableSteerer = (*session)(nil) + _ agent.FunctionResultSubmitter = (*session)(nil) ) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index 5e0447bb7..c6d497577 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -60,16 +60,12 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d return fail(fmt.Errorf("Claude SDK requires an explicit state root")) } config = Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(options.StateRoot, "daemon", options.Profile, "runtime", "claude-sdk")} - binding, err := localworkspace.Load() - if err != nil { - return fail(err) - } - if binding != nil { + if binding := localworkspace.Current(); binding != nil { config.Node, err = filepath.EvalSymlinks(node) if err != nil { return fail(err) } - config, err = ConfigureLocal(config, options.StateRoot, options.RuntimeRoot, os.Getenv("OAC_RUNTIME_WORKSPACE")) + config, err = ConfigureLocal(config, options.StateRoot, options.RuntimeRoot, binding.Workspace()) if err != nil { return fail(err) } @@ -89,7 +85,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d 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.RetainedNativeHistory, local && info.SDK == declaration.Declaration.Native.Version) agent.Narrow(&caps.FunctionTools, !local || info.SupportsWorkspaceFunctions()) agent.Narrow(&caps.MessageImages, info.SupportsMessageImages()) agent.Narrow(&caps.FunctionResultImages, info.SupportsFunctionResultImages()) diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index f5376c81b..1e6510eaa 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -12,6 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/contracttest" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { @@ -47,7 +48,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) { - info := RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} + info := RuntimeInfo{SDK: declaration.Declaration.Native.Version, Native: "native", Features: features} return info, nil }) supported := len(features) > 0 && features[0] == "mcp_http_tools" @@ -75,7 +76,7 @@ func TestDiscoveryOnlyNarrowsDeclaration(t *testing.T) { 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 + return RuntimeInfo{SDK: declaration.Declaration.Native.Version, Native: "native", Features: features}, nil }) if out == nil || !out.Info.Available { t.Fatal("runtime unavailable") diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index e35e48068..ffd8c8230 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -18,13 +18,13 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { 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("Original input."), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "native-model", "system_prompt": "Keep these exact instructions.\nDo not replace them."}} - ordinary, _, err := prepare(config, request) + ordinary, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) } request.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "medium"}, TextVerbosity: "medium"} before, _ := json.Marshal(request) - controlled, _, err := prepare(config, request) + controlled, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) } @@ -38,7 +38,7 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { } // Without a search control Claude Code keeps its native web tools. request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} - absent, _, err := prepare(config, request) + absent, _, err := prepareConfiguration(config, request) if err != nil || !reflect.DeepEqual(ordinary, absent) { t.Fatal("absent search control changed the native configuration", err) } @@ -48,14 +48,14 @@ 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{ + none, _, err := prepareConfiguration(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) + workspace, _, err := prepareConfiguration(workspaceFixture(t), request) if err != nil || workspace.WebSearch != "live" { t.Fatal("live search on a workspace did not reach the bridge", err) } @@ -97,7 +97,7 @@ func TestReasoningEffortReachesNativeOptions(t *testing.T) { 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) + start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(effort, err) } @@ -128,21 +128,21 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { 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."), 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) + start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) } - if start.OutputFormat == nil || string(start.OutputFormat.Schema) != string(schema) || *start.Input[0].Content[0].Text != *request.Input[0].Content[0].Text || start.SystemPrompt != "Original instructions." { + if start.OutputFormat == nil || string(start.OutputFormat.Schema) != string(schema) || start.SystemPrompt != "Original instructions." { t.Fatal("native configuration changed") } request.ExecutionControls.OutputFormat.Schema = json.RawMessage(`{"type":"object","const":9007199254740993}`) - if _, _, err := prepare(config, request); err == nil { + if _, _, err := prepareConfiguration(config, request); err == nil { t.Fatal("lossy schema accepted") } request.ExecutionControls.OutputFormat.Schema = schema 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 { + if start, _, err := prepareConfiguration(config, request); err != nil || start.OutputFormat == nil { t.Fatal("native structured output was narrowed by MCP", err) } } @@ -155,17 +155,17 @@ func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) {Name: "lookup", Description: "Lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"const":"original"}}}`), DeferLoading: true}, {Name: "clock", Description: "Clock", Parameters: json.RawMessage(`{"type":"object"}`)}, }} - start, _, err := prepare(config, request) + start, _, err := prepareConfiguration(config, request) if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions, request.FunctionTools) { t.Fatal("function discovery changed native definitions", err) } 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 { + if start, _, err := prepareConfiguration(config, request); err != nil || !start.ToolSearch || start.OutputFormat == nil { t.Fatal("native tool search was narrowed by structured output", err) } request.ExecutionControls = nil request.ToolSearch = false - if _, _, err := prepare(config, request); err == nil { + if _, _, err := prepareConfiguration(config, request); err == nil { t.Fatal("deferred definitions became eager") } } @@ -177,7 +177,7 @@ func TestReasoningSummaryReachesTheBridgeOnlyWhenRequested(t *testing.T) { 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) + start, _, err := prepareConfiguration(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.go b/apps/daemon/internal/agent/claudesdk/executor.go index 5b99aed4f..4fc143057 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -1,6 +1,7 @@ package claudesdk import ( + "bufio" "context" "encoding/json" "errors" @@ -55,8 +56,6 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { if err != nil { return nil, err } - base.reads.supported = slices.Contains(info.Features, "workspace_read") - base.directories.supported = slices.Contains(info.Features, "workspace_directory") e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume} go e.read() if err = e.write(start); err == nil { @@ -124,7 +123,8 @@ func (e *executor) write(value any) error { func (e *executor) read() { stderrDone := make(chan struct{}) go func() { _, _ = io.Copy(io.Discard, e.base.process.Stderr); close(stderrDone) }() - scanner := e.base.bridgeOutput() + scanner := bufio.NewScanner(e.base.process.Stdout) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) ready := false readyFailure := errors.New("claudesdk: Executor readiness failed") for scanner.Scan() { @@ -167,8 +167,6 @@ func (e *executor) read() { } <-stderrDone _ = e.base.process.Wait() - e.base.stopWorkspaceReads() - e.base.stopWorkspaceDirectories() e.mu.Lock() e.invalid = true if e.active != nil { @@ -201,11 +199,7 @@ func (e *executor) StartTurn(ctx context.Context, run string, input proto.Messag go s.runTurn(start, out) // Once the write is attempted, a lost receipt has an unknown input outcome. stopWrite := context.AfterFunc(ctx, s.invalidate) - err := e.write(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - Input proto.MessageInput `json:"input"` - }{"turn_start", run, input}) + err := e.write(turnStartFrame{"turn_start", run, input}) stopWrite() if err != nil { s.invalidate() @@ -306,10 +300,3 @@ func (s *session) invalidate() { s.process.Cancel() } } - -func (e *executor) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - return e.base.ReadWorkspaceFile(ctx, path, maxBytes) -} -func (e *executor) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - return e.base.ListWorkspaceDirectory(ctx, path, maxEntries) -} diff --git a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go index 15d683ccf..e6ad19996 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -36,11 +36,11 @@ func helperTurn(scanner *bufio.Scanner, request *startRequest) (func(bridgeEvent os.Exit(4) } var start struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - Input json.RawMessage `json:"input"` + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input proto.MessageInput `json:"input"` } - if json.Unmarshal(scanner.Bytes(), &start) != nil || start.Type != "turn_start" || start.TurnID == "" || json.Unmarshal(start.Input, &request.Input) != nil { + if json.Unmarshal(scanner.Bytes(), &start) != nil || start.Type != "turn_start" || start.TurnID == "" { os.Exit(5) } return helperTurnOutput(scanner, start.TurnID) diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go deleted file mode 100644 index 16059494d..000000000 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ /dev/null @@ -1,203 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -// Explicit opt-in acceptance; normal test runs never contact a provider. -func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { - entry, keyPath, proof := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_PROOF_DIR") - if entry == "" || keyPath == "" || proof == "" { - t.Skip("explicit installed runtime, private key file and proof directory required") - } - endpoint, model := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_BASE_URL"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_MODEL") - if endpoint == "" || model == "" { - t.Fatal("explicit provider endpoint and model required") - } - if !filepath.IsAbs(proof) { - t.Fatal("absolute proof directory required") - } - if err := os.MkdirAll(proof, 0700); err != nil { - t.Fatal(err) - } - key, err := os.ReadFile(keyPath) - if err != nil { - t.Fatal("cannot read selected credential file") - } - t.Setenv("OAC_RUNTIME_HOME", proof) - config := Config{Node: os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_NODE"), Entrypoint: entry, StateDir: filepath.Join(proof, "state"), Env: []string{ - "ANTHROPIC_BASE_URL=" + endpoint, "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(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, - }} - if proxy := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_HTTP_PROXY"); proxy != "" { - config.Env = append(config.Env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy, "http_proxy="+proxy, "https_proxy="+proxy) - } - ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) - defer cancel() - readiness, err := CheckRuntime(ctx, config) - if err != nil { - t.Fatal("installed runtime readiness failed", err) - } - type turnEvidence struct { - Run string `json:"run"` - NodePID int `json:"node_pid"` - NativePIDs []int `json:"native_pids"` - FirstTextMS int64 `json:"first_text_ms"` - ElapsedMS int64 `json:"elapsed_ms"` - CancelMS int64 `json:"cancel_ms,omitempty"` - Cancelled bool `json:"cancelled"` - CancellationError string `json:"cancellation_error,omitempty"` - Settlement agent.TurnSettlement `json:"settlement"` - SettlementError string `json:"settlement_error,omitempty"` - Done proto.DonePayload `json:"done"` - Errors []string `json:"errors,omitempty"` - } - evidence := struct { - Runtime RuntimeInfo `json:"runtime"` - PrepareMS int64 `json:"prepare_ms"` - Turns []turnEvidence `json:"turns"` - Recovered bool `json:"recovered_after_cancel"` - Closed bool `json:"closed"` - }{Runtime: readiness} - persist := func() { - raw, _ := json.MarshalIndent(evidence, "", " ") - _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) - } - defer persist() - 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) - if err != nil { - t.Fatal("executor preparation failed", err) - } - evidence.PrepareMS = time.Since(prepared).Milliseconds() - defer func() { - closeCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) - defer stop() - evidence.Closed = owner.Close(closeCtx) == nil - persist() - }() - nativeChildren := func(pid int) []int { - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) - var ids []int - for _, value := range strings.Fields(string(raw)) { - child, _ := strconv.Atoi(value) - args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", child)) - if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { - ids = append(ids, child) - } - } - slices.Sort(ids) - return ids - } - run := func(id, prompt string, cancelOnText bool) turnEvidence { - t.Helper() - started := time.Now() - record := turnEvidence{Run: id, NodePID: owner.(*executor).base.process.Cmd.Process.Pid} - output := make(chan proto.Envelope, 128) - turn, err := owner.StartTurn(ctx, id, proto.TextInput(prompt), output) - if err != nil || turn == nil { - t.Fatal("Turn start failed", err) - } - cancellation := make(chan error, 1) - var cancelAt time.Time - for event := range output { - if event.ID != id { - t.Fatal("output crossed Turn identity") - } - if event.Type == proto.TypeDelta && record.FirstTextMS == 0 { - record.FirstTextMS = time.Since(started).Milliseconds() - record.NativePIDs = nativeChildren(record.NodePID) - if cancelOnText { - record.Cancelled = true - cancelAt = time.Now() - go func() { - cancelCtx, stop := context.WithTimeout(ctx, 30*time.Second) - defer stop() - cancellation <- turn.Cancel(cancelCtx) - }() - } - } - if event.Type == proto.TypeError { - var failure proto.ErrorPayload - _ = event.DecodePayload(&failure) - record.Errors = append(record.Errors, failure.Error) - } - if event.Type == proto.TypeDone { - _ = event.DecodePayload(&record.Done) - } - } - if record.Cancelled { - err := <-cancellation - record.CancelMS = time.Since(cancelAt).Milliseconds() - if err != nil { - record.CancellationError = err.Error() - } - } - record.Settlement, err = turn.AwaitSettlement(ctx) - if err != nil { - record.SettlementError = err.Error() - } - record.ElapsedMS = time.Since(started).Milliseconds() - evidence.Turns = append(evidence.Turns, record) - persist() - t.Logf("turn=%s bridge_pid=%d native_pids=%v reusable=%t first_text_ms=%d total_ms=%d", id, record.NodePID, record.NativePIDs, record.Settlement.Reusable, record.FirstTextMS, record.ElapsedMS) - return record - } - marker := "REUSE-" + uuid.NewString() - first := run("first", "Remember this marker: "+marker+". Reply with exactly the marker.", false) - if first.SettlementError != "" || !first.Settlement.Reusable || !strings.Contains(first.Done.Content, marker) { - t.Fatal("first Turn failed or was not reusable") - } - second := run("second", "What exact marker did I give you? Reply with only that marker.", false) - if second.SettlementError != "" || !second.Settlement.Reusable || !strings.Contains(second.Done.Content, marker) || first.NodePID != second.NodePID || len(first.NativePIDs) == 0 || !slices.Equal(first.NativePIDs, second.NativePIDs) { - t.Fatal("ordinary Turns did not retain native execution and history") - } - interrupted := run("cancel", "List the numbers 1 through 10000, one number per line, without stopping early.", true) - if !interrupted.Cancelled { - t.Fatal("native output ended before cancellation boundary") - } - if interrupted.SettlementError != "" || !interrupted.Settlement.Reusable { - if interrupted.Settlement.Reason == "" && interrupted.SettlementError == "" { - t.Fatal("invalid executor omitted its reason") - } - if err := owner.Close(ctx); err != nil { - t.Fatal("invalid owner cleanup was not confirmed", err) - } - native, _ := interrupted.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if native == "" { - native, _ = second.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - } - request.AgentSessionID = native - request.RequireExistingNativeSession = true - owner, err = factory(ctx, request) - if err != nil { - t.Fatal("history recovery failed", err) - } - evidence.Recovered = true - } - continued := run("continued", "What exact marker did I originally give you? Reply with only the marker.", false) - if continued.SettlementError != "" || !continued.Settlement.Reusable || !strings.Contains(continued.Done.Content, marker) { - t.Fatal("history did not continue after cancellation") - } - if !evidence.Recovered && (continued.NodePID != second.NodePID || !slices.Equal(continued.NativePIDs, second.NativePIDs)) { - t.Fatal("reusable cancellation replaced native execution") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index 05fdb543e..964f2be19 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -98,6 +98,15 @@ func runPersistentExecutorHelper() { encode(bridgeEvent{Type: "delta", TurnID: old, ItemID: "item", Delta: "late"}) continue } + if frame := os.Getenv("SDK_REPLAY_FRAME"); frame != "" { + // Replay one shared-fixture frame as this Turn's only bridge output. + var fields map[string]any + _ = json.Unmarshal([]byte(frame), &fields) + fields["turn_id"] = active + _ = json.NewEncoder(os.Stdout).Encode(fields) + settle(true) + continue + } encode(bridgeEvent{Type: "turn_started", TurnID: active}) encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "item", Delta: "partial"}) diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go index 6aef261f1..972c6b39b 100644 --- a/apps/daemon/internal/agent/claudesdk/functions.go +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -128,11 +128,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio if err := result.ValidateContent(); err != nil { return err } - data, err := json.Marshal(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - proto.FunctionResultPayload - }{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) + data, err := json.Marshal(functionResultFrame{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) if err != nil { return err } diff --git a/apps/daemon/internal/agent/claudesdk/installation.go b/apps/daemon/internal/agent/claudesdk/installation.go index 2bc814f6a..c4b5fa196 100644 --- a/apps/daemon/internal/agent/claudesdk/installation.go +++ b/apps/daemon/internal/agent/claudesdk/installation.go @@ -4,18 +4,19 @@ import ( "context" "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" "path/filepath" "runtime" ) func Installation() agent.Installation { - return agent.Installation{AgentKind: "claude_sdk", Version: "0.3.269", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + return agent.Installation{AgentKind: declaration.Declaration.Kind, Version: declaration.Declaration.Native.Version, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, Environment: func(dir, node string) map[string]string { - return map[string]string{"OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT": filepath.Join(dir, "dist", "main.js"), "OAC_RUNTIME_CLAUDE_SDK_NODE": node} + return map[string]string{claudeSDKEntrypointEnv: filepath.Join(dir, "dist", "main.js"), claudeSDKNodeEnv: node} }, Check: func(ctx context.Context, dir, node string, env []string) error { got, err := CheckRuntime(ctx, Config{Node: node, Entrypoint: filepath.Join(dir, "dist", "main.js"), Env: env}) - if err != nil || got.SDK != "0.3.269" || !got.SupportsLocalRuntime() { + if err != nil || got.SDK != declaration.Declaration.Native.Version || !got.SupportsLocalRuntime() { return fmt.Errorf("Claude installation is incompatible; Windows requires Git Bash") } return nil diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go deleted file mode 100644 index c3da9e8ef..000000000 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ /dev/null @@ -1,403 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -func TestLiveClaudeSDKTextResume(t *testing.T) { - entrypoint := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT") - keyFile := os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") - if entrypoint == "" || keyFile == "" { - t.Skip("real SDK/provider acceptance requires explicit entrypoint and private key file") - } - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal(err) - } - proofRoot := os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") - if !filepath.IsAbs(proofRoot) { - t.Fatal("OAC_TEST_CLAUDE_SDK_PROOF_DIR must be an absolute managed proof directory") - } - if err := os.MkdirAll(proofRoot, 0o700); err != nil { - t.Fatal(err) - } - root, err := os.MkdirTemp(proofRoot, "claude-adapter-") - if err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", root) - target, _ := url.Parse("https://api.minimax.cn/anthropic") - proxy := httputil.NewSingleHostReverseProxy(target) - director := proxy.Director - proxy.Director = func(req *http.Request) { director(req); req.Host = target.Host } - proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) { - http.Error(w, "provider transport failed", http.StatusBadGateway) - } - var mu sync.Mutex - type providerRequest struct { - Model string `json:"model"` - Tools []struct { - Name string `json:"name"` - } `json:"tools"` - } - var requests []providerRequest - var delayNext atomic.Bool - var delayedRequests atomic.Int32 - forwarder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method == "POST" && strings.HasSuffix(req.URL.Path, "/messages") { - body, err := io.ReadAll(req.Body) - if err != nil { - http.Error(w, "request read failed", 400) - return - } - _ = req.Body.Close() - req.Body = io.NopCloser(bytes.NewReader(body)) - var value providerRequest - _ = json.Unmarshal(body, &value) - mu.Lock() - requests = append(requests, value) - mu.Unlock() - } - if req.Method == "POST" && strings.HasSuffix(req.URL.Path, "/messages") && delayNext.Swap(false) { - delayedRequests.Add(1) - select { - case <-time.After(31 * time.Second): - case <-req.Context().Done(): - return - } - } - proxy.ServeHTTP(w, req) - })) - defer forwarder.Close() - config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ - "ANTHROPIC_BASE_URL=" + forwarder.URL, "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), - "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", - "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3", - }} - - // Ambient project configuration must not add a model tool or start a server. - work := filepath.Join(config.StateDir, "work") - if err := os.MkdirAll(filepath.Join(work, ".claude"), 0o700); err != nil { - t.Fatal(err) - } - canary := filepath.Join(root, "ambient-mcp-started") - ambient, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"ambient": map[string]any{ - "command": "node", "args": []string{"-e", "require('node:fs').writeFileSync(process.argv[1], 'unexpected')", canary}, - }}}) - if err := os.WriteFile(filepath.Join(work, ".mcp.json"), ambient, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(work, ".claude", "settings.json"), []byte(`{"enableAllProjectMcpServers":true,"permissions":{"allow":["Bash","Read","Agent","WebSearch"]}}`), 0o600); err != nil { - t.Fatal(err) - } - type evidence struct { - ExecutionControls *proto.ExecutionControls `json:"execution_controls"` - SteeringText string `json:"steering_text,omitempty"` - SteeringWritten bool `json:"steering_written,omitempty"` - SteeringConfirmed bool `json:"steering_confirmed,omitempty"` - SteeringMilliseconds int64 `json:"steering_milliseconds,omitempty"` - SessionID string `json:"session_id"` - NodePID int `json:"node_pid"` - NativePIDs []int `json:"native_pids"` - ChildPIDs []int `json:"child_pids"` - Text string `json:"text"` - Failure string `json:"failure,omitempty"` - Events []proto.Envelope `json:"events"` - FunctionCalls int `json:"function_calls"` - AppliedResults int `json:"applied_results"` - ProviderRequests []providerRequest `json:"provider_requests"` - } - functionNonce := "function-" + uuid.NewString() - run := func(prompt, resume string, success *bool, steering ...string) evidence { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - mu.Lock() - requestStart := len(requests) - mu.Unlock() - out := make(chan proto.Envelope, 64) - 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}`)}} - } - running, err := startSingleTurn(ctx, config, request, out) - if err != nil { - t.Fatal(err) - } - s := running.(*session) - defer running.Cancel(context.Background()) - proof := evidence{NodePID: s.process.Cmd.Process.Pid, ExecutionControls: request.ExecutionControls} - if len(steering) > 0 { - proof.SteeringText = steering[0] - } - type steeringResult struct { - err error - elapsed int64 - written bool - } - steeringReply := make(chan steeringResult, 1) - var steeringAt time.Time - beginSteering := func() { - if proof.SteeringText == "" || !steeringAt.IsZero() { - return - } - steeringAt = time.Now() - if success != nil { - delayNext.Store(true) - } - go func() { - callCtx, cancel := context.WithCancel(ctx) - defer cancel() - timer := time.AfterFunc(10*time.Second, cancel) - defer timer.Stop() - written := false - err := s.SteerWithReceipt(callCtx, proto.PromptSteerPayload{InputID: uuid.NewString(), Input: proto.TextInput(proof.SteeringText)}, func() { written = timer.Stop() }) - steeringReply <- steeringResult{err: err, elapsed: time.Since(steeringAt).Milliseconds(), written: written} - }() - } - type children struct{ all, native []int } - observed := make(chan children, 1) - go func() { - pids := map[int]bool{} - native := map[int]bool{} - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - for { - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", proof.NodePID, proof.NodePID)) - for _, value := range strings.Fields(string(raw)) { - if pid, err := strconv.Atoi(value); err == nil { - pids[pid] = true - // SDK history lookup may also spawn Git helpers; identify the execution transport. - args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && - bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { - native[pid] = true - } - } - } - select { - case <-s.process.Done(): - var result children - for pid := range pids { - result.all = append(result.all, pid) - } - for pid := range native { - result.native = append(result.native, pid) - } - observed <- result - return - case <-ticker.C: - } - } - }() - done := false - for event := range out { - proof.Events = append(proof.Events, event) - switch event.Type { - case proto.TypeDelta: - if success == nil { - beginSteering() - } - case proto.TypeToolCall: - var tool proto.ToolCallPayload - if err := event.DecodePayload(&tool); err != nil { - t.Fatal(err) - } - if tool.Observation == nil || tool.Observation.Kind != "function" || (tool.Stage != "before" && tool.Stage != "after") { - t.Fatal("invalid live neutral function observation") - } - if tool.Stage == "after" { - expectedStatus := "completed" - if success != nil && !*success { - expectedStatus = "failed" - } - if tool.Observation.Status != expectedStatus || tool.Observation.Content == nil || len(*tool.Observation.Content) != 2 { - t.Fatal("live result observation lost status or content") - } - } - case proto.TypeFunctionCall: - var call proto.FunctionCallPayload - if err := event.DecodePayload(&call); err != nil { - t.Fatal(err) - } - proof.FunctionCalls++ - beginSteering() - if success == nil || proof.FunctionCalls != 1 || call.Name != "lookup" { - t.Fatal("unexpected live function call") - } - first, second := functionNonce, "ordered-second-part" - if !*success { - first, second = "synthetic-current-failure", "do-not-retry" - } - value := proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: uuid.NewString(), Success: *success, Content: []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} - if err := s.SubmitFunctionResult(ctx, value); err != nil { - t.Fatalf("live native result receipt failed: %v; proof root %s", err, root) - } - proof.AppliedResults++ - case proto.TypeError: - var payload proto.ErrorPayload - _ = json.Unmarshal(event.Payload, &payload) - proof.Failure = payload.Error - case proto.TypeDone: - done = true - var payload proto.DonePayload - _ = json.Unmarshal(event.Payload, &payload) - proof.Text = payload.Content - proof.SessionID, _ = payload.Metadata[proto.DoneMetaAgentSessionID].(string) - } - } - // Done reports the Turn outcome; startSingleTurn closes its Executor - // after output settlement. Verify release at that boundary. - if _, err := s.AwaitSettlement(ctx); err != nil { - t.Fatal(err) - } - select { - case <-s.process.Done(): - case <-time.After(5 * time.Second): - t.Fatal("single-Turn Executor retained its process after settlement") - } - if !steeringAt.IsZero() { - receipt := <-steeringReply - if receipt.err != nil { - proof.Failure = "steering receipt: " + receipt.err.Error() - } else { - proof.SteeringConfirmed = true - } - proof.SteeringMilliseconds = receipt.elapsed - proof.SteeringWritten = receipt.written - } - released := <-observed - proof.NativePIDs, proof.ChildPIDs = released.native, released.all - if !done { - t.Fatal("no daemon completion before timeout") - } - for _, pid := range proof.ChildPIDs { - if value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) == 0 || fields[0] != "Z" { - t.Fatalf("SDK child %d remains alive after Done", pid) - } - } - } - - mu.Lock() - proof.ProviderRequests = append([]providerRequest{}, requests[requestStart:]...) - mu.Unlock() - for _, sent := range proof.ProviderRequests { - if sent.Model != "MiniMax-M3" { - t.Fatal("unexpected provider model", sent.Model) - } - want := 0 - if success != nil { - want = 1 - } - if len(sent.Tools) != want { - t.Fatal("native tool inventory widened", sent.Tools) - } - for _, tool := range sent.Tools { - if tool.Name != "mcp__functions__lookup" { - t.Fatal("undeclared native tool", tool.Name) - } - } - } - if _, err := os.Stat(canary); !os.IsNotExist(err) { - t.Fatal("ambient MCP configuration was not excluded", err) - } - data, err := json.MarshalIndent(proof, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", proof.NodePID)), data, 0o600); err != nil { - t.Fatal(err) - } - return proof - } - nonce := "sdk-adapter-" + uuid.NewString() - first := run("Remember this exact verification value and reply with it: "+nonce, "", nil) - if first.Failure != "" || first.SessionID == "" || !strings.Contains(first.Text, nonce) || len(first.NativePIDs) == 0 { - t.Fatalf("first execution failed: %+v; evidence root %s", first, root) - } - verifyMessageEvents(t, first.Events, first.Text) - second := run("Return only the exact verification value from the previous user message.", first.SessionID, nil) - if second.Failure != "" || second.SessionID != first.SessionID || !strings.Contains(second.Text, nonce) || first.NodePID == second.NodePID || len(second.NativePIDs) == 0 { - t.Fatalf("cold resume failed: %+v; evidence root %s", second, root) - } - verifyMessageEvents(t, second.Events, second.Text) - for _, a := range first.NativePIDs { - for _, b := range second.NativePIDs { - if a == b { - t.Fatal("native process was reused") - } - } - } - accepted, rejected := true, false - functionFirst := run("Call lookup once with id 42 as a string. Report both returned parts verbatim.", "", &accepted) - if functionFirst.Failure != "" || functionFirst.FunctionCalls != 1 || functionFirst.AppliedResults != 1 || !strings.Contains(functionFirst.Text, functionNonce) || !strings.Contains(functionFirst.Text, "ordered-second-part") { - t.Fatalf("live function failed: %+v", functionFirst) - } - functionSecond := run("Call lookup once with id 42 as a string. Report the prior verification value and both current result parts. Do not retry.", functionFirst.SessionID, &rejected) - if functionSecond.Failure != "" || functionSecond.FunctionCalls != 1 || functionSecond.AppliedResults != 1 || functionSecond.SessionID != functionFirst.SessionID || !strings.Contains(functionSecond.Text, functionNonce) || !strings.Contains(functionSecond.Text, "synthetic-current-failure") || !strings.Contains(functionSecond.Text, "do-not-retry") || functionSecond.NodePID == functionFirst.NodePID { - t.Fatalf("live function resume failed: %+v", functionSecond) - } - steeringNonce := "live-steering-" + uuid.NewString() - steered := run("Write twelve short numbered observations about trees. Use no tools.", "", nil, "Remember this additional verification value and return it verbatim: "+steeringNonce) - if steered.Failure != "" || !steered.SteeringConfirmed || !steered.SteeringWritten || !strings.Contains(steered.Text, steeringNonce) { - t.Fatalf("live steering failed: %+v", steered) - } - steeredResume := run("Return only the exact live-steering verification value from the previous conversation.", steered.SessionID, nil) - if steeredResume.Failure != "" || steeredResume.SessionID != steered.SessionID || !strings.Contains(steeredResume.Text, steeringNonce) || steeredResume.NodePID == steered.NodePID { - t.Fatalf("steered cold continuation failed: %+v", steeredResume) - } - functionSteered := run("Call lookup once with id 42 as a string. Report both returned parts verbatim.", "", &accepted, "Also remember and report this value: "+steeringNonce) - if functionSteered.Failure != "" || !functionSteered.SteeringConfirmed || !functionSteered.SteeringWritten || functionSteered.SteeringMilliseconds < 31000 || delayedRequests.Load() != 1 || functionSteered.FunctionCalls != 1 || functionSteered.AppliedResults != 1 || !strings.Contains(functionSteered.Text, steeringNonce) || !strings.Contains(functionSteered.Text, functionNonce) { - t.Fatalf("live function steering failed: %+v", functionSteered) - } - for _, completed := range []evidence{first, second, functionFirst, functionSecond, steered, steeredResume, functionSteered} { - verifyLiveUsageEvents(t, completed.Events) - } - mu.Lock() - before := len(requests) - mu.Unlock() - missing := run("Say hello.", uuid.NewString(), nil) - mu.Lock() - measured := append([]providerRequest{}, requests...) - mu.Unlock() - if !strings.Contains(missing.Failure, "history_unavailable") || missing.SessionID != "" || len(missing.NativePIDs) != 0 || len(measured) != before { - t.Fatalf("missing history did not fail before native/model start: %+v", missing) - } - if len(measured) < 2 { - t.Fatal("expected real model requests") - } - for _, request := range measured { - if request.Model != "MiniMax-M3" { - t.Fatalf("unexpected requested model %q", request.Model) - } - } - data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> official SDK -> real MiniMax with default typed execution controls, active input, native continuation and disabled environment/subagent tools; public API not enabled; no filesystem isolation claim", "turns": []evidence{first, second, functionFirst, functionSecond, steered, steeredResume, functionSteered}, "missing_history": missing, "model_requests": measured, "controlled_provider_delay_seconds": 31, "delayed_requests": delayedRequests.Load()}, "", " ") - if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { - t.Fatal(err) - } - t.Logf("real adapter proof: %s", filepath.Join(root, "proof.json")) -} diff --git a/apps/daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go index 951fa0ae8..09ff0d219 100644 --- a/apps/daemon/internal/agent/claudesdk/local_test.go +++ b/apps/daemon/internal/agent/claudesdk/local_test.go @@ -15,7 +15,7 @@ func TestLocalWorkspaceBindingAndRequiredHistory(t *testing.T) { req := workspaceRequest() req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: config.Workspace.Directory} req.RequireExistingNativeSession = true - start, _, err := prepare(config, req) + start, _, err := prepareConfiguration(config, req) if err != nil || !start.RequireHistory { t.Fatal(start, err) } @@ -26,7 +26,7 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.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", "context_window": 64000, "max_output_tokens": 4096} - start, env, err := prepare(config, req) + start, env, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) } @@ -42,7 +42,7 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { } 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") { + if _, _, err := prepareConfiguration(config, req); err == nil || strings.Contains(err.Error(), "secret") { t.Fatal("unsafe provider accepted or disclosed") } } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 69a52785b..6fd5830cd 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -25,7 +25,7 @@ func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} seen := map[string]bool{} for range 2 { - start, env, err := prepare(config, req) + start, env, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) } @@ -67,7 +67,7 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} - if _, _, err := prepare(config, req); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { + if _, _, err := prepareConfiguration(config, req); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { t.Fatal("invalid bearer accepted or unsafe error returned") } entries, err := os.ReadDir(root) diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index d1c87211e..d614578ea 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) { @@ -17,10 +16,10 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) token := "selected-user-token" t.Setenv("MCP_TOKEN", "unselected-native-token") 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}, + {InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/local", Server: proto.PluginMCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, + {InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/remote", Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, }} - start, env, err := prepare(config, req) + start, env, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) } @@ -63,7 +62,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { e.MCP[0].Server.URL = "http://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"}}}} + environment := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/remote", Server: proto.PluginMCPServer{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") diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index f219d101d..25ba5ab6b 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -46,7 +46,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { case "environment": req.DisableExecutionEnvironment = false } - start, _, err := prepare(config, req) + start, _, err := prepareConfiguration(config, req) 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/native_controls_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/native_controls_live_linux_test.go deleted file mode 100644 index d3170948f..000000000 --- a/apps/daemon/internal/agent/claudesdk/native_controls_live_linux_test.go +++ /dev/null @@ -1,87 +0,0 @@ -//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/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 665896f47..d82cfd9d1 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -30,7 +30,6 @@ type startRequest struct { Subagents *subagentOptions `json:"subagents,omitempty"` OutputFormat *proto.OutputFormat `json:"output_format,omitempty"` Type string `json:"type"` - Input proto.MessageInput `json:"input,omitempty"` Model string `json:"model"` SystemPrompt string `json:"system_prompt"` Cwd string `json:"cwd"` @@ -41,20 +40,8 @@ type startRequest struct { RequireHistory bool `json:"require_history,omitempty"` } -func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { - if req.RunID == "" || req.Input.Validate() != nil { - return startRequest{}, nil, fmt.Errorf("claudesdk: run id and prompt are required") - } - start, env, err := prepareConfiguration(config, req) - if err != nil { - return startRequest{}, nil, err - } - start.Input = req.Input - return start, env, nil -} - func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { - start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, Functions: req.FunctionTools} + start := startRequest{Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, Functions: req.FunctionTools} fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) } diff --git a/apps/daemon/internal/agent/claudesdk/options_test.go b/apps/daemon/internal/agent/claudesdk/options_test.go index 3eb0a859f..c3dc1305d 100644 --- a/apps/daemon/internal/agent/claudesdk/options_test.go +++ b/apps/daemon/internal/agent/claudesdk/options_test.go @@ -25,7 +25,7 @@ func TestNullableSystemPrompt(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")} - start, _, err := prepare(config, proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: tc.options}) + start, _, err := prepareConfiguration(config, proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: tc.options}) if (err != nil) != tc.reject || (!tc.reject && start.SystemPrompt != tc.want) { t.Fatalf("system prompt %q, error %v", start.SystemPrompt, err) } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 67e60abc9..58805600b 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -38,7 +38,7 @@ func preparationRequest() proto.PromptRequestPayload { func runPreparationHelper() { mode := os.Getenv("SDK_HELPER_MODE") if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { - features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "workspace_read", "workspace_directory"} + features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations"} if mode == "old-runtime" { features = []string{"workspace_tools"} } else if mode == "old-command-runtime" { @@ -85,14 +85,6 @@ func runPreparationHelper() { } } emit(bridgeEvent{Type: "executor_ready", Protocol: 3}) - if strings.HasPrefix(mode, "directory-") { - runWorkspaceDirectoryHelper(scanner, state, mode) - return - } - if strings.HasPrefix(mode, "read-") { - runWorkspaceReadHelper(scanner, state, mode) - return - } if !scanner.Scan() { return } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 8040a0b4d..27a4436ad 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -60,7 +60,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if err := json.Unmarshal(raw, &frozen); err != nil { t.Fatal(err) } - if frozen.Model != "fixture" || frozen.Resume != "native-session" || frozen.Workspace == nil || len(frozen.Input) != 0 { + if frozen.Model != "fixture" || frozen.Resume != "native-session" || frozen.Workspace == nil { t.Fatal("configuration-only request was not retained") } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" diff --git a/apps/daemon/internal/agent/claudesdk/provider_test.go b/apps/daemon/internal/agent/claudesdk/provider_test.go index e719738b7..be172013b 100644 --- a/apps/daemon/internal/agent/claudesdk/provider_test.go +++ b/apps/daemon/internal/agent/claudesdk/provider_test.go @@ -35,7 +35,7 @@ func TestWorkspaceProviderHeadersReachNativeOptions(t *testing.T) { config.Env = append(config.Env, "ANTHROPIC_CUSTOM_HEADERS=X-Stale: config") req := workspaceRequest() req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "https://example.test", "api_key": "fixture", "headers": map[string]string{"X-Activity": "daily-free"}} - start, env, err := prepare(config, req) + start, env, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) } @@ -48,7 +48,7 @@ func TestWorkspaceProviderHeadersReachNativeOptions(t *testing.T) { } } delete(req.AgentOptions["model_provider"].(map[string]any), "headers") - _, env, err = prepare(config, req) + _, env, err = prepareConfiguration(config, req) if err != nil || !slices.Contains(env, "ANTHROPIC_CUSTOM_HEADERS=") { t.Fatal("headerless provider did not clear ambient headers", err) } diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index b5e55ee34..d0bea805c 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -23,14 +23,38 @@ type session struct { cancelOutput chan struct{} nativeEnded bool - reads workspaceReadState - directories workspaceDirectoryState - process *clirunner.Process - writeMu *sync.Mutex - functions functionState - steering steeringState - settled chan struct{} - outcome proto.DonePayload + process *clirunner.Process + writeMu *sync.Mutex + functions functionState + steering steeringState + settled chan struct{} + outcome proto.DonePayload +} + +// Frames the Executor writes to the bridge; bridgeEvent is what the bridge sends back. +// packages/claude-sdk-adapter/fixtures/bridge-frames.json is checked against both. +type turnStartFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input proto.MessageInput `json:"input"` +} + +type turnCancelFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` +} + +type steerFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + InputID string `json:"input_id"` + Input proto.MessageInput `json:"input"` +} + +type functionResultFrame struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + proto.FunctionResultPayload } type bridgeEvent struct { diff --git a/apps/daemon/internal/agent/claudesdk/steering.go b/apps/daemon/internal/agent/claudesdk/steering.go index 4db08b1b3..dbe660c0e 100644 --- a/apps/daemon/internal/agent/claudesdk/steering.go +++ b/apps/daemon/internal/agent/claudesdk/steering.go @@ -38,12 +38,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP if strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || input.Input.Validate() != nil { return fmt.Errorf("%w: input identity and text are required", agent.ErrSteeringRejected) } - data, err := json.Marshal(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - InputID string `json:"input_id"` - Input proto.MessageInput `json:"input"` - }{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input}) + data, err := json.Marshal(steerFrame{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input}) if err != nil || len(data) > 1024*1024 { return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected) } diff --git a/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go deleted file mode 100644 index 586c4c0b6..000000000 --- a/apps/daemon/internal/agent/claudesdk/subagents_live_linux_test.go +++ /dev/null @@ -1,197 +0,0 @@ -//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 0acaef1b4..4293d38f1 100644 --- a/apps/daemon/internal/agent/claudesdk/subagents_test.go +++ b/apps/daemon/internal/agent/claudesdk/subagents_test.go @@ -9,18 +9,18 @@ import ( func TestSubagentConfigurationUsesExplicitRequestAndFrozenLimit(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() - start, _, err := prepare(config, req) + start, _, err := prepareConfiguration(config, req) if err != nil || start.Subagents != nil { t.Fatal("ordinary execution changed", err) } req.DisableSubagents, req.ObserveSubagentIdentities = false, true - start, _, err = prepare(config, req) + start, _, err = prepareConfiguration(config, req) if err != nil || start.Subagents == nil || start.Subagents.MaxConcurrent != 6 { t.Fatal("missing default native admission limit", err) } limit := 2 req.MaxConcurrentSubagents = &limit - start, _, err = prepare(config, req) + start, _, err = prepareConfiguration(config, req) limit = 4 if err != nil || start.Subagents.MaxConcurrent != 2 { t.Fatal("subagent configuration was not frozen", err) @@ -36,7 +36,7 @@ func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { req := workspaceRequest() req.DisableSubagents, req.ObserveSubagentIdentities = false, true change(&req) - if _, _, err := prepare(config, req); err == nil { + if _, _, err := prepareConfiguration(config, req); err == nil { t.Fatal("unqualified subagent combination accepted") } } diff --git a/apps/daemon/internal/agent/claudesdk/unsupported.go b/apps/daemon/internal/agent/claudesdk/unsupported.go deleted file mode 100644 index 154d904a4..000000000 --- a/apps/daemon/internal/agent/claudesdk/unsupported.go +++ /dev/null @@ -1,15 +0,0 @@ -package claudesdk - -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} - -func (s *session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/claudesdk/unsupported_test.go b/apps/daemon/internal/agent/claudesdk/unsupported_test.go deleted file mode 100644 index 7fe6048c5..000000000 --- a/apps/daemon/internal/agent/claudesdk/unsupported_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -// Nil concrete receivers prove Unsupported needs no native owner, transport, -// workspace access or input retention. Callers still preserve the separate -// nil-Turn ownership rule on required lifecycle methods. -func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { - ctx := context.Background() - const secret = "private-fixture-value" - check := func(err error) { - t.Helper() - if !errors.Is(err, agent.ErrUnsupportedOperation) || err.Error() == agent.ErrUnsupportedOperation.Error() { - t.Fatalf("expected explicit unsupported result with reason, got %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatal("unsupported error disclosed input") - } - } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil)} { - result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) - check(err) - if result.SizeBytes != 0 { - t.Fatal("unsupported write fabricated receipt") - } - } -} diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index 0c3af818f..d3c49bf3c 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -73,7 +73,7 @@ func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { } var want map[string]any _ = json.Unmarshal([]byte(original), &want) - if observed.Provider != "claude_code" || !reflect.DeepEqual(observed.Raw["claude_sdk_result"], want) || observed.Model != "" || observed.Tokens != nil || observed.CostUSD != 0 || observed.InputTokens != 0 || observed.OutputTokens != 0 { + if observed.Provider != "claude_code" || !reflect.DeepEqual(observed.Raw["claude_sdk_result"], want) || observed.Model != "" || observed.Tokens != nil { t.Fatalf("usage normalized or lost: %+v", observed) } } @@ -113,53 +113,3 @@ func runUsageHelper(request startRequest, mode string, encode func(bridgeEvent)) } encode(bridgeEvent{Type: "result", SessionID: id, Text: "final"}) } - -func verifyLiveUsageEvents(t *testing.T, events []proto.Envelope) { - t.Helper() - var observed proto.Usage - count := 0 - for _, event := range events { - switch event.Type { - case proto.TypeUsage: - var payload proto.UsagePayload - if err := event.DecodePayload(&payload); err != nil { - t.Fatal(err) - } - observed = payload.Usage - count++ - case proto.TypeDone: - var payload proto.DonePayload - if err := event.DecodePayload(&payload); err != nil { - t.Fatal(err) - } - if count < 1 || !reflect.DeepEqual(payload.Usage, observed) { - t.Fatal("missing, repeated or changed live usage") - } - } - } - if count < 1 || observed.Tokens != nil || observed.Model != "" || observed.CostUSD != 0 { - t.Fatal("live native evidence became unsupported public accounting") - } - if count > 1 { - snapshots, ok := observed.Raw["claude_sdk_results"].([]any) - if !ok || len(snapshots) != count || !reflect.DeepEqual(snapshots[count-1], observed.Raw["claude_sdk_result"]) { - t.Fatal("lost native-turn snapshots") - } - } - snapshot, ok := observed.Raw["claude_sdk_result"].(map[string]any) - if !ok || snapshot["subtype"] != "success" || snapshot["is_error"] != false { - t.Fatal("missing native result provenance") - } - main, ok := snapshot["usage"].(map[string]any) - if !ok { - t.Fatal("missing main-loop usage") - } - output, ok := main["output_tokens"].(float64) - if !ok || output <= 0 { - t.Fatal("missing real output count") - } - models, ok := snapshot["modelUsage"].(map[string]any) - if !ok || len(models) == 0 { - t.Fatal("missing per-model native usage") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index e554a0581..e76457cc4 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -8,7 +8,6 @@ import ( "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" ) @@ -23,14 +22,14 @@ type WorkspaceConfig struct { } type workspaceProfile struct { - ToolEnv map[string]string `json:"tool_env,omitempty"` - CapabilityRoot string `json:"capability_root,omitempty"` - MCP []environmentMCPServer `json:"mcp,omitempty"` - Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` - Home string `json:"home"` - State string `json:"state"` - Scratch string `json:"scratch"` - EnvNames []string `json:"env_names"` + ToolEnv map[string]string `json:"tool_env,omitempty"` + CapabilityRoot string `json:"capability_root,omitempty"` + MCP []environmentMCPServer `json:"mcp,omitempty"` + Skills []proto.InstalledSkill `json:"skills,omitempty"` + Home string `json:"home"` + State string `json:"state"` + Scratch string `json:"scratch"` + EnvNames []string `json:"env_names"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go deleted file mode 100644 index b2ff0fabe..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func liveWorkspaceCommands(t *testing.T, runID string, events []proto.Envelope, commands []string) []proto.ToolCallPayload { - t.Helper() - started := map[string]string{} - finished := map[string]bool{} - var complete []proto.ToolCallPayload - terminal := false - for _, event := range events { - if event.Type == proto.TypeDone { - terminal = true - } - if event.Type != proto.TypeToolCall { - continue - } - var call proto.ToolCallPayload - if terminal || event.ID != runID || event.DecodePayload(&call) != nil || call.ID == "" || call.Observation == nil || call.Observation.Kind != "command" { - t.Fatal("invalid command frame or execution identity") - } - o := call.Observation - if o.ExitCode != nil || o.Cwd != nil || o.DurationMS != nil { - t.Fatal("command observation invented unavailable native metadata") - } - switch call.Stage { - case "before": - if len(started) >= len(commands) || started[call.ID] != "" || o.Command != commands[len(started)] || o.Status != "in_progress" || len(o.Output) != 0 { - t.Fatal("unexpected, duplicate or fabricated command start") - } - started[call.ID] = o.Command - case "after": - if started[call.ID] != o.Command || finished[call.ID] || o.Status == "in_progress" { - t.Fatal("command completion lacks a unique matching start") - } - finished[call.ID] = true - complete = append(complete, call) - default: - t.Fatal("invalid command stage") - } - } - if len(complete) != len(commands) || len(started) != len(commands) { - t.Fatal("command observations missing or replayed from an earlier query") - } - return complete -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory.go b/apps/daemon/internal/agent/claudesdk/workspace_directory.go deleted file mode 100644 index 1bde6cbe5..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory.go +++ /dev/null @@ -1,237 +0,0 @@ -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "io" - "io/fs" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/google/uuid" -) - -const workspaceDirectoryMaxEntries = 1000 -const workspaceDirectoryTimeout = 12 * time.Second - -type workspaceDirectoryState struct { - mu sync.Mutex - supported bool - closed bool - uncertain bool - pending *workspaceDirectory -} -type workspaceDirectory struct { - id string - maxEntries int - done chan struct{} - result agent.WorkspaceDirectoryResult - err error -} -type workspaceDirectoryEvent struct { - Type string `json:"type"` - ID string `json:"id"` - Entries *[]workspaceDirectoryEntry `json:"entries"` - Truncated *bool `json:"truncated"` - Error string `json:"error"` -} - -var _ agent.WorkspaceDirectoryLister = (*session)(nil) - -func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - if s.owner != nil { - return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) - } - read, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return s.awaitWorkspaceDirectory(ctx, read) -} - -func (s *session) admitWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (*workspaceDirectory, error) { - w := &s.directories - w.mu.Lock() - defer w.mu.Unlock() - if !w.supported { - return nil, agent.ErrWorkspaceReadUnsupported - } - if w.uncertain { - return nil, agent.ErrWorkspaceReadUncertain - } - if w.closed || ctx == nil || ctx.Err() != nil || s.process.Context().Err() != nil { - return nil, agent.ErrWorkspaceReadUnavailable - } - if maxEntries < 1 || maxEntries > workspaceDirectoryMaxEntries || len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { - return nil, agent.ErrWorkspaceReadInvalid - } - for _, part := range strings.Split(path, "/") { - if path == "" { - break - } - if part == "" || part == "." || part == ".." { - return nil, agent.ErrWorkspaceReadInvalid - } - } - if w.pending != nil { - return nil, agent.ErrWorkspaceReadBusy - } - read := &workspaceDirectory{id: uuid.NewString(), maxEntries: maxEntries, done: make(chan struct{})} - frame, err := json.Marshal(struct { - Type string `json:"type"` - ID string `json:"id"` - Path string `json:"directory"` - MaxEntries int `json:"max_entries"` - }{"workspace_directory", read.id, path, maxEntries}) - if err != nil || len(frame)+1 > 8192 { - return nil, agent.ErrWorkspaceReadInvalid - } - w.pending = read - deadline := time.Now().Add(workspaceDirectoryTimeout) - if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { - deadline = requested - } - go func() { - timer := time.NewTimer(time.Until(deadline)) - defer timer.Stop() - select { - case <-read.done: - return - case <-timer.C: - } - s.failWorkspaceDirectory(read) - }() - go func() { - s.writeMu.Lock() - _, err := s.process.Stdin.Write(append(frame, '\n')) - s.writeMu.Unlock() - if err != nil { - s.failWorkspaceDirectory(read) - } - }() - return read, nil -} - -func (s *session) failWorkspaceDirectory(read *workspaceDirectory) { - s.directories.mu.Lock() - pending := s.directories.pending == read - if pending { - s.directories.uncertain = true - s.directories.pending = nil - read.err = agent.ErrWorkspaceReadUncertain - s.process.Cancel() - close(read.done) - } - s.directories.mu.Unlock() -} - -func (s *session) awaitWorkspaceDirectory(_ context.Context, read *workspaceDirectory) (agent.WorkspaceDirectoryResult, error) { - // Caller cancellation cannot discard an already admitted native wait. - <-read.done - return read.result, read.err -} - -func (s *session) receiveWorkspaceDirectory(raw []byte) bool { - var event workspaceDirectoryEvent - if json.Unmarshal(raw, &event) != nil || event.Type != "workspace_directory" { - return false - } - w := &s.directories - w.mu.Lock() - defer w.mu.Unlock() - read := w.pending - if read == nil || event.ID != read.id { - w.uncertain = true - s.process.Cancel() - return true - } - read.err = agent.ErrWorkspaceReadUncertain - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - valid := decoder.Decode(&event) == nil && decoder.Decode(new(any)) == io.EOF - if !valid { - w.uncertain = true - w.pending = nil - close(read.done) - s.process.Cancel() - return true - } - if event.Error != "" && event.Entries == nil && event.Truncated == nil { - switch event.Error { - case "not_found": - read.err = fs.ErrNotExist - case "permission": - read.err = fs.ErrPermission - case "invalid": - read.err = agent.ErrWorkspaceReadInvalid - case "busy": - read.err = agent.ErrWorkspaceReadBusy - case "unavailable": - read.err = agent.ErrWorkspaceReadUnavailable - } - } else if event.Error == "" && event.Entries != nil && event.Truncated != nil { - entries, valid := validWorkspaceDirectoryEntries(*event.Entries, read.maxEntries) - if valid && (!*event.Truncated || len(entries) == read.maxEntries) { - read.result = agent.WorkspaceDirectoryResult{Entries: entries, Truncated: *event.Truncated} - read.err = nil - } - } - if read.err == agent.ErrWorkspaceReadUncertain { - w.uncertain = true - s.process.Cancel() - } - w.pending = nil - close(read.done) - return true -} - -func (s *session) stopWorkspaceDirectories() { - w := &s.directories - w.mu.Lock() - defer w.mu.Unlock() - w.closed = true - if w.pending != nil { - read := w.pending - w.pending = nil - w.uncertain = true - read.err = agent.ErrWorkspaceReadUncertain - close(read.done) - } -} - -type workspaceDirectoryEntry struct { - Name string `json:"name"` - Kind string `json:"kind"` - SizeBytes *int64 `json:"size_bytes,omitempty"` -} - -func validWorkspaceDirectoryEntries(raw []workspaceDirectoryEntry, maxEntries int) ([]agent.WorkspaceDirectoryEntry, bool) { - if raw == nil || len(raw) > maxEntries { - return nil, false - } - entries := make([]agent.WorkspaceDirectoryEntry, 0, len(raw)) - names := make(map[string]bool, len(raw)) - for _, entry := range raw { - if entry.Name == "" || entry.Name == "." || entry.Name == ".." || strings.ContainsAny(entry.Name, "/\x00") || names[entry.Name] { - return nil, false - } - names[entry.Name] = true - switch entry.Kind { - case "file": - if entry.SizeBytes == nil || *entry.SizeBytes < 0 { - return nil, false - } - case "directory", "symlink", "other": - if entry.SizeBytes != nil { - return nil, false - } - default: - return nil, false - } - entries = append(entries, agent.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) - } - return entries, true -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go deleted file mode 100644 index d6afad379..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go +++ /dev/null @@ -1,103 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "context" - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func liveWorkspaceDirectoryFixtures(t *testing.T, root string) { - t.Helper() - for _, name := range []string{"directory-empty", "directory-denied"} { - if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { - t.Fatal(err) - } - } - if err := os.Chmod(filepath.Join(root, "directory-denied"), 0); err != nil { - t.Fatal(err) - } - for name, target := range map[string]string{"directory-inside-link": "directory-empty", "directory-outside-link": filepath.Dir(root)} { - if err := os.Symlink(target, filepath.Join(root, name)); err != nil { - t.Fatal(err) - } - } -} - -func liveWorkspaceDirectories(t *testing.T, ctx context.Context, reader agent.WorkspaceReader, root, stage string) { - t.Helper() - lister, ok := reader.(agent.WorkspaceDirectoryLister) - if !ok { - t.Fatal("workspace directory owner missing") - } - result, err := lister.ListWorkspaceDirectory(ctx, "", 1000) - if err != nil || result.Truncated { - t.Fatal("workspace directory failed", stage, err) - } - expected, err := os.ReadDir(root) - if err != nil || len(expected) != len(result.Entries) { - t.Fatal("directory length differs", stage, err) - } - found := make(map[string]agent.WorkspaceDirectoryEntry) - for _, entry := range result.Entries { - found[entry.Name] = entry - } - for _, entry := range expected { - actual, ok := found[entry.Name()] - if !ok { - t.Fatal("missing directory entry", stage, entry.Name()) - } - stat, err := os.Lstat(filepath.Join(root, entry.Name())) - if err != nil { - t.Fatal(err) - } - switch { - case stat.Mode().IsRegular(): - // The command heartbeat is intentionally changing during live execution. - if actual.Kind != "file" || actual.SizeBytes == nil || (entry.Name() != "heartbeat.txt" && *actual.SizeBytes != stat.Size()) { - t.Fatal("file metadata differs", stage, actual) - } - case stat.IsDir(): - if actual.Kind != "directory" || actual.SizeBytes != nil { - t.Fatal(actual) - } - case stat.Mode()&os.ModeSymlink != 0: - if actual.Kind != "symlink" || actual.SizeBytes != nil { - t.Fatal(actual) - } - } - } - empty, err := lister.ListWorkspaceDirectory(ctx, "directory-empty", 2) - if err != nil || empty.Truncated || len(empty.Entries) != 0 { - t.Fatal(empty, err) - } - bounded, err := lister.ListWorkspaceDirectory(ctx, "", 1) - if err != nil || !bounded.Truncated || len(bounded.Entries) != 1 { - t.Fatal(bounded, err) - } - for _, path := range []string{"directory-inside-link", "directory-outside-link"} { - if _, err := lister.ListWorkspaceDirectory(ctx, path, 2); !errors.Is(err, fs.ErrPermission) && !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatal("directory denial missing", path, err) - } - } - if _, err := lister.ListWorkspaceDirectory(ctx, "directory-denied", 2); !errors.Is(err, fs.ErrPermission) { - t.Fatal("permission denial missing", err) - } - if _, err := lister.ListWorkspaceDirectory(ctx, "directory-missing", 2); !errors.Is(err, fs.ErrNotExist) { - t.Fatal(err) - } - raw, err := json.MarshalIndent(result, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(filepath.Dir(root), "directory-"+stage+".json"), raw, 0600); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go deleted file mode 100644 index e073cffae..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go +++ /dev/null @@ -1,214 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - - "context" - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { - turnID := "" - for scanner.Scan() { - var req struct { - Type, ID string - TurnID string `json:"turn_id"` - Path string `json:"directory"` - Max int `json:"max_entries"` - } - _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "turn_cancel" { - reusable := false - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) - confirmed := true - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) - return - } - if req.Type == "turn_start" { - turnID = req.TurnID - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) - _ = os.WriteFile(filepath.Join(state, "directory-started"), []byte("{}"), 0600) - continue - } - _ = os.WriteFile(filepath.Join(state, "directory-admitted"), []byte("{}"), 0600) - if mode == "directory-held" { - for { - if _, err := os.Stat(filepath.Join(state, "directory-release")); err == nil { - break - } - time.Sleep(time.Millisecond) - } - } - if mode == "directory-exit" { - return - } - entries := []map[string]any{{"name": "file", "kind": "file", "size_bytes": 3}} - if req.Path == "empty" { - entries = []map[string]any{} - } - response := map[string]any{"type": "workspace_directory", "id": req.ID, "entries": entries, "truncated": false} - switch req.Path { - case "uncertain", "invalid", "not_found", "permission": - response = map[string]any{"type": "workspace_directory", "id": req.ID, "error": req.Path} - case "bad-kind": - entries[0]["kind"] = "unknown" - case "wrong-id": - response["id"] = "other" - case "extra": - response["extra"] = true - case "bad-size": - entries[0]["size_bytes"] = -1 - case "missing-size": - delete(entries[0], "size_bytes") - case "duplicate": - response["entries"] = append(entries, entries[0]) - case "escape-name": - entries[0]["name"] = "../secret" - case "null": - response["entries"] = nil - } - - _ = json.NewEncoder(os.Stdout).Encode(response) - } -} - -func directoryExecutor(t *testing.T, mode string) (*executor, Config) { - t.Helper() - config := preparationFixture(t, mode) - resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - e := resource.(*executor) - t.Cleanup(func() { _ = e.Close(context.Background()) }) - return e, config -} - -func TestWorkspaceDirectoryBoundsAndMetadata(t *testing.T) { - e, _ := directoryExecutor(t, "directory-normal") - for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("accepted %q: %v", path, err) - } - } - for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { - if _, err := e.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(limit, err) - } - } - result, err := e.ListWorkspaceDirectory(t.Context(), "", 4) - if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { - t.Fatal(result, err) - } - empty, err := e.ListWorkspaceDirectory(t.Context(), "empty", 4) - if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { - t.Fatal(empty, err) - } - for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { - if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { - t.Fatal(path, err) - } - } - if _, err := e.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceDirectoryDetachAndTurnStart(t *testing.T) { - e, config := directoryExecutor(t, "directory-held") - ctx, detach := context.WithCancel(t.Context()) - defer detach() - result := make(chan error, 1) - go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - detach() - if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { - t.Fatal(err) - } - out := make(chan proto.Envelope, 16) - running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) - } - select { - case err := <-result: - t.Fatal("caller detach discarded native wait", err) - default: - } - if err := os.WriteFile(filepath.Join(config.StateDir, "directory-release"), nil, 0600); err != nil { - t.Fatal(err) - } - if err := <-result; err != nil { - t.Fatal(err) - } - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-started")) - if _, err := running.(agent.WorkspaceDirectoryLister).ListWorkspaceDirectory(t.Context(), "file", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { - for _, path := range []string{"uncertain", "wrong-id", "bad-kind", "bad-size", "missing-size", "duplicate", "escape-name", "null", "extra"} { - t.Run(path, func(t *testing.T) { - e, _ := directoryExecutor(t, "directory-normal") - result, err := e.ListWorkspaceDirectory(t.Context(), path, 4) - if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { - t.Fatal(result, err) - } - if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } - }) - } - for _, mode := range []string{"directory-held", "directory-exit"} { - t.Run(mode, func(t *testing.T) { - e, config := directoryExecutor(t, mode) - done := make(chan error, 1) - go func() { _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - if mode == "directory-held" { - if err := e.Close(t.Context()); err != nil { - t.Fatal(err) - } - } - select { - case err := <-done: - if err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("native waiter lost on release") - } - }) - } -} - -func TestWorkspaceDirectoryDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - e, config := directoryExecutor(t, "directory-held") - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - done := make(chan error, 1) - go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - if err := <-done; err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - if e.base.process.Context().Err() == nil { - t.Fatal("uncertain deadline returned before owner cancellation") - } - if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Turn") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go deleted file mode 100644 index df08c4bb2..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ /dev/null @@ -1,233 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -// Run only inside a separately qualified outer placement, with its pinned native -// dependencies. This fixture does not create isolation or public admission. -func TestLiveClaudeWorkspaceFactory(t *testing.T) { - configFile := os.Getenv("OAC_TEST_CLAUDE_WORKSPACE_LIVE_CONFIG") - if configFile == "" { - t.Skip("requires explicit qualified placement and real provider configuration") - } - var placement struct { - Node, Entrypoint, Proof, Scratch, KeyFile, DependencyPath, Proxy string - } - raw, err := os.ReadFile(configFile) - if err != nil || json.Unmarshal(raw, &placement) != nil { - t.Fatal("invalid private live configuration") - } - root, err := os.MkdirTemp(placement.Proof, "factory-") - if err != nil { - t.Fatal(err) - } - t.Logf("workspace factory proof: %s", root) - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv("OAC_TEST_PARENT_SECRET", "parent-must-not-enter-workspace") - key, err := os.ReadFile(placement.KeyFile) - if err != nil || len(bytes.TrimSpace(key)) == 0 { - t.Fatal("private real-provider key unavailable") - } - scratch, err := os.MkdirTemp(placement.Scratch, "f-") - if err != nil { - t.Fatal(err) - } - config := Config{Node: placement.Node, Entrypoint: placement.Entrypoint, StateDir: filepath.Join(root, "state"), - Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: scratch}, - Env: []string{"ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_API_KEY=", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3"}} - if placement.Proxy != "" { - config.Env = append(config.Env, "HTTP_PROXY="+placement.Proxy, "HTTPS_PROXY="+placement.Proxy, "NO_PROXY=127.0.0.1,localhost") - } - for _, dir := range []string{config.StateDir, config.Workspace.Directory, config.Workspace.HomeDir} { - if err := os.Mkdir(dir, 0o700); err != nil { - t.Fatal(err) - } - } - liveWorkspaceReadFixtures(t, config.Workspace.Directory) - heartbeat := filepath.Join(config.Workspace.Directory, "heartbeat.txt") - artifact := filepath.Join(config.Workspace.Directory, "value.txt") - type evidence struct { - Reads []liveWorkspaceRead `json:"reads,omitempty"` - RunID string `json:"run_id"` - Events []proto.Envelope `json:"events"` - Done proto.DonePayload `json:"done"` - Failure string `json:"failure,omitempty"` - Cancelled bool `json:"cancelled"` - CancelMS int64 `json:"cancel_ms,omitempty"` - BridgePID int `json:"bridge_pid"` - Terminals int `json:"terminals"` - Heartbeats []string `json:"heartbeats,omitempty"` - } - writeEvidence := func(name string, proof evidence) { - t.Helper() - encoded, err := json.MarshalIndent(proof, "", " ") - if err != nil || bytes.Contains(encoded, bytes.TrimSpace(key)) { - t.Fatal("cannot safely encode factory observations") - } - if err := os.WriteFile(filepath.Join(root, name+".json"), encoded, 0o600); err != nil { - t.Fatal(err) - } - } - run := func(name, prompt, resume string, cancelOnEffect bool) evidence { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second) - defer cancel() - out := make(chan proto.Envelope, 64) - req := workspaceRequest() - req.RunID, req.Input, req.AgentSessionID = uuid.NewString(), proto.TextInput(prompt), resume - 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) - if err != nil { - if name == "missing-history" && running == nil && strings.Contains(err.Error(), "history_unavailable") { - proof.Failure = err.Error() - writeEvidence(name, proof) - return proof - } - t.Fatal(err) - } - s := running.(*session) - defer s.Cancel(context.Background()) - proof.BridgePID = s.process.Cmd.Process.Pid - proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "active", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - ticker := time.NewTicker(80 * time.Millisecond) - defer ticker.Stop() - for out != nil { - select { - case <-ctx.Done(): - t.Fatal("real factory deadline expired") - case <-ticker.C: - value, _ := os.ReadFile(heartbeat) - if cancelOnEffect && !proof.Cancelled && len(value) > 0 && string(value) != "0" && string(value) != "1" { - proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "effect", "value.txt")...) - started := time.Now() - if err := s.Cancel(ctx); err != nil { - t.Fatal("factory cancellation failed", err) - } - proof.CancelMS = time.Since(started).Milliseconds() - proof.Cancelled = true - } - case event, ok := <-out: - if !ok { - out = nil - continue - } - proof.Events = append(proof.Events, event) - switch event.Type { - case proto.TypeError: - var failure proto.ErrorPayload - _ = event.DecodePayload(&failure) - proof.Failure = failure.Error - case proto.TypeDone: - proof.Terminals++ - _ = event.DecodePayload(&proof.Done) - } - } - } - if proof.Cancelled { - a, _ := os.ReadFile(heartbeat) - time.Sleep(1500 * time.Millisecond) - b, _ := os.ReadFile(heartbeat) - proof.Heartbeats = []string{string(a), string(b)} - if len(a) == 0 || !bytes.Equal(a, b) { - t.Fatal("native command effects continued after Cancel") - } - settled, _ := json.Marshal(s.CancellationOutcome()) - done, _ := json.Marshal(proof.Done) - if !bytes.Equal(settled, done) { - 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") - } - return proof - } - commands := []string{ - `python3 -c "import sys; print('OBS_SUCCESS_STDOUT'); print('OBS_SUCCESS_STDERR', file=sys.stderr)"`, - `python3 -c "import sys; print('OBS_FAILURE_STDOUT'); print('OBS_FAILURE_STDERR', file=sys.stderr); sys.exit(7)"`, - } - observed := run("commands", fmt.Sprintf("Execute exactly these two foreground Bash calls, sequentially, with timeout 10000. Preserve each command exactly. The second intentionally fails; do not retry or repair it. Use no other tools.\n1. %s\n2. %s", commands[0], commands[1]), "", false) - observedID, _ := observed.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if observedID == "" || observed.Failure != "" { - t.Fatal("real command observation query failed") - } - completed := liveWorkspaceCommands(t, observed.RunID, observed.Events, commands) - for i, expected := range []struct{ status, output string }{ - {"completed", "OBS_SUCCESS_STDERR\nOBS_SUCCESS_STDOUT"}, - {"failed", "Exit code 7\nOBS_FAILURE_STDERR\nOBS_FAILURE_STDOUT"}, - } { - var output string - if completed[i].Observation.Status != expected.status || json.Unmarshal(completed[i].Observation.Output, &output) != nil || output != expected.output { - t.Fatal("native command result text/status was not preserved") - } - } - nonce := "conversation-" + uuid.NewString() - command := "python3 -u - <<'VERIFY_PY'\nimport secrets,time\nfrom pathlib import Path\nPath('value.txt').write_text(secrets.token_hex(16)+'\\n')\nfor n in range(180):\n Path('heartbeat.txt').write_text(str(n))\n time.sleep(1)\nVERIFY_PY" - first := run("first", fmt.Sprintf("Remember the conversation-only value %s; do not write it into any file. Execute exactly one foreground Bash call with timeout 120000 and this exact command. Wait for it; use no other tools.\n%s", nonce, command), observedID, true) - id, _ := first.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || id != observedID { - t.Fatal("cancelled native Session identity unavailable") - } - interrupted := liveWorkspaceCommands(t, first.RunID, first.Events, []string{command}) - if interrupted[0].ID == completed[0].ID || interrupted[0].ID == completed[1].ID || - (interrupted[0].Observation.Status != "incomplete" && interrupted[0].Observation.Status != "failed") || - (interrupted[0].Observation.Status == "failed" && len(interrupted[0].Observation.Output) == 0) { - t.Fatal("cancelled command lost its native failure or incomplete observation") - } - original, err := os.ReadFile(artifact) - if err != nil || len(bytes.TrimSpace(original)) != 32 { - t.Fatal("actual workspace artifact missing") - } - second := run("resumed", "Use native Read to read value.txt. Then use native Edit to append the literal suffix -resumed to its value, retaining a trailing newline. Do not use Bash. Reply with the original file value and the conversation-only value remembered earlier.", id, false) - liveWorkspaceCommands(t, second.RunID, second.Events, nil) - final, err := os.ReadFile(artifact) - if err != nil || string(final) != strings.TrimSpace(string(original))+"-resumed\n" || second.Failure != "" || - second.Done.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(second.Done.Content, nonce) || - !strings.Contains(second.Done.Content, strings.TrimSpace(string(original))) || first.BridgePID == second.BridgePID { - t.Fatal("fresh-process native workspace/history continuation failed") - } - retained := config.StateDir + "-retained" - if err := os.Rename(config.StateDir, retained); err != nil { - t.Fatal(err) - } - defer func() { - _ = os.Remove(config.StateDir) - _ = os.Rename(retained, config.StateDir) - }() - if err := os.Mkdir(config.StateDir, 0o700); err != nil { - t.Fatal(err) - } - missing := run("missing-history", "Continue the existing Session only; do not start another Session.", id, false) - entries, err := os.ReadDir(config.StateDir) - after, _ := os.ReadFile(artifact) - if missing.Failure != "claudesdk: history_unavailable" || missing.Terminals != 0 || err != nil || len(entries) != 0 || !bytes.Equal(final, after) || missing.Done.Metadata[proto.DoneMetaAgentSessionID] != nil { - t.Fatal("missing native history did not fail before new execution") - } - if err := os.RemoveAll(scratch); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read.go b/apps/daemon/internal/agent/claudesdk/workspace_read.go deleted file mode 100644 index ec5ea4a56..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_read.go +++ /dev/null @@ -1,196 +0,0 @@ -package claudesdk - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "io" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/google/uuid" -) - -const workspaceReadMaxBytes = 1 << 20 -const workspaceReadTimeout = 12 * time.Second - -type workspaceReadState struct { - mu sync.Mutex - supported bool - closed bool - uncertain bool - pending *workspaceRead -} -type workspaceRead struct { - id string - maxBytes int - done chan struct{} - result agent.WorkspaceReadResult - err error -} -type workspaceReadEvent struct { - Type string `json:"type"` - ID string `json:"id"` - Data *string `json:"data_base64"` - Truncated *bool `json:"truncated"` - Error string `json:"error"` -} - -var _ agent.WorkspaceReader = (*session)(nil) - -func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - if s.owner != nil { - return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) - } - read, err := s.admitWorkspaceRead(ctx, path, maxBytes) - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return s.awaitWorkspaceRead(ctx, read) -} - -func (s *session) admitWorkspaceRead(ctx context.Context, path string, maxBytes int) (*workspaceRead, error) { - w := &s.reads - w.mu.Lock() - defer w.mu.Unlock() - if !w.supported { - return nil, agent.ErrWorkspaceReadUnsupported - } - if w.uncertain { - return nil, agent.ErrWorkspaceReadUncertain - } - if w.closed || ctx == nil || ctx.Err() != nil || s.process.Context().Err() != nil { - return nil, agent.ErrWorkspaceReadUnavailable - } - if maxBytes < 1 || maxBytes > workspaceReadMaxBytes || path == "" || len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { - return nil, agent.ErrWorkspaceReadInvalid - } - for _, part := range strings.Split(path, "/") { - if part == "" || part == "." || part == ".." { - return nil, agent.ErrWorkspaceReadInvalid - } - } - if w.pending != nil { - return nil, agent.ErrWorkspaceReadBusy - } - read := &workspaceRead{id: uuid.NewString(), maxBytes: maxBytes, done: make(chan struct{})} - frame, err := json.Marshal(struct { - Type string `json:"type"` - ID string `json:"id"` - Path string `json:"path"` - MaxBytes int `json:"max_bytes"` - }{"workspace_read", read.id, path, maxBytes}) - if err != nil || len(frame)+1 > 8192 { - return nil, agent.ErrWorkspaceReadInvalid - } - w.pending = read - deadline := time.Now().Add(workspaceReadTimeout) - if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { - deadline = requested - } - go func() { - timer := time.NewTimer(time.Until(deadline)) - defer timer.Stop() - select { - case <-read.done: - return - case <-timer.C: - } - s.failWorkspaceRead(read) - }() - go func() { - s.writeMu.Lock() - _, err := s.process.Stdin.Write(append(frame, '\n')) - s.writeMu.Unlock() - if err != nil { - s.failWorkspaceRead(read) - } - }() - return read, nil -} - -func (s *session) failWorkspaceRead(read *workspaceRead) { - s.reads.mu.Lock() - pending := s.reads.pending == read - if pending { - s.reads.uncertain = true - s.reads.pending = nil - read.err = agent.ErrWorkspaceReadUncertain - s.process.Cancel() - close(read.done) - } - s.reads.mu.Unlock() -} - -func (s *session) awaitWorkspaceRead(_ context.Context, read *workspaceRead) (agent.WorkspaceReadResult, error) { - // Caller cancellation cannot discard an already admitted native wait. - <-read.done - return read.result, read.err -} - -func (s *session) receiveWorkspaceRead(raw []byte) bool { - var event workspaceReadEvent - if json.Unmarshal(raw, &event) != nil || event.Type != "workspace_read" { - return false - } - w := &s.reads - w.mu.Lock() - defer w.mu.Unlock() - read := w.pending - if read == nil || event.ID != read.id { - w.uncertain = true - s.process.Cancel() - return true - } - read.err = agent.ErrWorkspaceReadUncertain - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - valid := decoder.Decode(&event) == nil && decoder.Decode(new(any)) == io.EOF - if !valid { - w.uncertain = true - w.pending = nil - close(read.done) - s.process.Cancel() - return true - } - if event.Error != "" && event.Data == nil && event.Truncated == nil { - switch event.Error { - case "invalid": - read.err = agent.ErrWorkspaceReadInvalid - case "busy": - read.err = agent.ErrWorkspaceReadBusy - case "unavailable": - read.err = agent.ErrWorkspaceReadUnavailable - } - } else if event.Error == "" && event.Data != nil && event.Truncated != nil { - data, err := base64.StdEncoding.Strict().DecodeString(*event.Data) - if err == nil && base64.StdEncoding.EncodeToString(data) == *event.Data && len(data) <= read.maxBytes && (!*event.Truncated || len(data) == read.maxBytes) { - read.result = agent.WorkspaceReadResult{Data: data, Truncated: *event.Truncated} - read.err = nil - } - } - if read.err == agent.ErrWorkspaceReadUncertain { - w.uncertain = true - s.process.Cancel() - } - w.pending = nil - close(read.done) - return true -} - -func (s *session) stopWorkspaceReads() { - w := &s.reads - w.mu.Lock() - defer w.mu.Unlock() - w.closed = true - if w.pending != nil { - read := w.pending - w.pending = nil - w.uncertain = true - read.err = agent.ErrWorkspaceReadUncertain - close(read.done) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go deleted file mode 100644 index e721126e7..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -type liveWorkspaceRead struct { - Stage string `json:"stage"` - Path string `json:"path"` - Bytes int `json:"bytes"` - SHA256 string `json:"sha256"` - Truncated bool `json:"truncated"` -} - -func liveWorkspaceReadFixtures(t *testing.T, root string) { - t.Helper() - liveWorkspaceDirectoryFixtures(t, root) - for path, data := range map[string][]byte{"read-binary.bin": bytes.Repeat([]byte{0, 255, 128, 1}, 64), "read-empty.bin": {}, "read-large.bin": bytes.Repeat([]byte{0, 255, 128, 1}, (workspaceReadMaxBytes+40)/4)} { - if err := os.WriteFile(filepath.Join(root, path), data, 0600); err != nil { - t.Fatal(err) - } - } -} - -func liveWorkspaceReads(t *testing.T, ctx context.Context, reader agent.WorkspaceReader, root, stage string, paths ...string) []liveWorkspaceRead { - t.Helper() - liveWorkspaceDirectories(t, ctx, reader, root, stage) - var proof []liveWorkspaceRead - for _, path := range paths { - expected, err := os.ReadFile(filepath.Join(root, path)) - if err != nil { - t.Fatal(err) - } - result, err := reader.ReadWorkspaceFile(ctx, path, workspaceReadMaxBytes) - if err != nil { - t.Fatal("actual native workspace read failed", stage, path, err) - } - truncated := len(expected) > workspaceReadMaxBytes - if truncated { - expected = expected[:workspaceReadMaxBytes] - } - if !bytes.Equal(result.Data, expected) || result.Truncated != truncated { - t.Fatal("native bytes differ", stage, path, len(result.Data), result.Truncated) - } - proof = append(proof, liveWorkspaceRead{Stage: stage, Path: path, Bytes: len(result.Data), SHA256: fmt.Sprintf("%x", sha256.Sum256(result.Data)), Truncated: result.Truncated}) - } - return proof -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go deleted file mode 100644 index 72d32a943..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go +++ /dev/null @@ -1,208 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { - turnID := "" - for scanner.Scan() { - var req struct { - Type, ID, Path string - TurnID string `json:"turn_id"` - Max int `json:"max_bytes"` - } - _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "turn_cancel" { - reusable := false - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) - confirmed := true - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) - return - } - if req.Type == "turn_start" { - turnID = req.TurnID - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) - _ = os.WriteFile(filepath.Join(state, "read-started"), []byte("{}"), 0600) - continue - } - _ = os.WriteFile(filepath.Join(state, "read-admitted"), []byte("{}"), 0600) - if mode == "read-held" { - for { - if _, err := os.Stat(filepath.Join(state, "read-release")); err == nil { - break - } - time.Sleep(time.Millisecond) - } - } - if mode == "read-exit" { - return - } - data := bytes.Repeat([]byte{0, 255, 1, 128}, req.Max/4) - if req.Path == "empty" { - data = nil - } - encoded := base64.StdEncoding.EncodeToString(data) - truncated := req.Path == "prefix" - response := map[string]any{"type": "workspace_read", "id": req.ID, "data_base64": encoded, "truncated": truncated} - switch req.Path { - case "uncertain": - response = map[string]any{"type": "workspace_read", "id": req.ID, "error": "uncertain"} - case "bad-base64": - response["data_base64"] = "!" - case "wrong-id": - response["id"] = "other" - case "extra": - response["extra"] = true - case "oversize": - response["data_base64"] = base64.StdEncoding.EncodeToString(make([]byte, req.Max+1)) - case "invalid": - response = map[string]any{"type": "workspace_read", "id": req.ID, "error": "invalid"} - } - _ = json.NewEncoder(os.Stdout).Encode(response) - } -} - -func readExecutor(t *testing.T, mode string) (*executor, Config) { - t.Helper() - config := preparationFixture(t, mode) - resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - e := resource.(*executor) - t.Cleanup(func() { _ = e.Close(context.Background()) }) - return e, config -} - -func TestWorkspaceReadBoundsAndBinary(t *testing.T) { - e, _ := readExecutor(t, "read-normal") - for _, path := range []string{"", "/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := e.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("accepted %q: %v", path, err) - } - } - for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { - if _, err := e.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(limit, err) - } - } - for _, path := range []string{"file", "prefix", "empty"} { - result, err := e.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) - expected := bytes.Repeat([]byte{0, 255, 1, 128}, workspaceReadMaxBytes/4) - if path == "empty" { - expected = nil - } - if err != nil || !bytes.Equal(result.Data, expected) || result.Truncated != (path == "prefix") { - t.Fatal(path, err, len(result.Data), result.Truncated) - } - } - if _, err := e.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(err) - } - if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceReadDetachAndTurnStart(t *testing.T) { - e, config := readExecutor(t, "read-held") - ctx, detach := context.WithCancel(t.Context()) - defer detach() - result := make(chan error, 1) - go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); result <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) - detach() - if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { - t.Fatal(err) - } - out := make(chan proto.Envelope, 16) - running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) - } - select { - case err := <-result: - t.Fatal("caller detach discarded native wait", err) - default: - } - if err := os.WriteFile(filepath.Join(config.StateDir, "read-release"), nil, 0600); err != nil { - t.Fatal(err) - } - if err := <-result; err != nil { - t.Fatal(err) - } - waitPreparationFile(t, filepath.Join(config.StateDir, "read-started")) - if _, err := running.(agent.WorkspaceReader).ReadWorkspaceFile(t.Context(), "file", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceReadUnknownAndRelease(t *testing.T) { - for _, path := range []string{"uncertain", "wrong-id", "bad-base64", "oversize", "extra"} { - t.Run(path, func(t *testing.T) { - e, _ := readExecutor(t, "read-normal") - result, err := e.ReadWorkspaceFile(t.Context(), path, 4) - if err != agent.ErrWorkspaceReadUncertain || len(result.Data) != 0 { - t.Fatal(result, err) - } - if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } - }) - } - for _, mode := range []string{"read-held", "read-exit"} { - t.Run(mode, func(t *testing.T) { - e, config := readExecutor(t, mode) - done := make(chan error, 1) - go func() { _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) - if mode == "read-held" { - if err := e.Close(t.Context()); err != nil { - t.Fatal(err) - } - } - select { - case err := <-done: - if err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("native waiter lost on release") - } - }) - } -} - -func TestWorkspaceReadDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - e, config := readExecutor(t, "read-held") - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - done := make(chan error, 1) - go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) - if err := <-done; err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - if e.base.process.Context().Err() == nil { - t.Fatal("uncertain deadline returned before owner cancellation") - } - if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Turn") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index c5949c07b..de2eac9a1 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -43,7 +43,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { config := workspaceFixture(t) t.Setenv("OAC_TEST_PARENT_SECRET", "parent-only") t.Setenv("ANTHROPIC_API_KEY", "unselected-provider") - start, env, err := prepare(config, workspaceRequest()) + start, env, err := prepareConfiguration(config, workspaceRequest()) if err != nil { t.Fatal(err) } @@ -112,7 +112,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { config.Env = append(config.Env, "ANTHROPIC_API_KEY=bad\x00value") } - if _, _, err := prepare(config, req); err == nil { + if _, _, err := prepareConfiguration(config, req); err == nil { t.Fatal("invalid binding or request accepted") } entries, err := os.ReadDir(config.StateDir) @@ -127,7 +127,7 @@ func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} - start, _, err := prepare(config, req) + start, _, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) } @@ -143,7 +143,7 @@ func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { 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}} - start, env, err := prepare(config, req) + start, env, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go index 97859b972..dde65b9f3 100644 --- a/apps/daemon/internal/agent/codex/contracts.go +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -5,15 +5,9 @@ import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Every public Harness implements each small contract explicitly. Unsupported // extensions return agent.ErrUnsupportedOperation without native effects. var ( - _ agent.Executor = (*Executor)(nil) - _ agent.Turn = (*Session)(nil) - _ agent.Session = (*Session)(nil) - _ agent.DurableSteerer = (*Session)(nil) - _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.WorkspaceReader = (*Session)(nil) - _ agent.WorkspaceDirectoryLister = (*Session)(nil) - _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.WorkspaceReader = (*Executor)(nil) - _ agent.WorkspaceDirectoryLister = (*Executor)(nil) - _ agent.WorkspaceWriter = (*Executor)(nil) + _ agent.Executor = (*Executor)(nil) + _ agent.Turn = (*Session)(nil) + _ agent.Session = (*Session)(nil) + _ agent.DurableSteerer = (*Session)(nil) + _ agent.FunctionResultSubmitter = (*Session)(nil) ) diff --git a/apps/daemon/internal/agent/codex/environment_local.go b/apps/daemon/internal/agent/codex/environment_local.go index 9fa3f3c2e..f6f9770eb 100644 --- a/apps/daemon/internal/agent/codex/environment_local.go +++ b/apps/daemon/internal/agent/codex/environment_local.go @@ -1,7 +1,6 @@ package codex import ( - "os" "runtime" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" @@ -9,9 +8,8 @@ import ( // SupportsLocalEnvironment checks deployment prerequisites, not public admission. func SupportsLocalEnvironment(version string) bool { - if !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" || (runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows") { + if !SupportsNativeSessionRecovery(version) || (runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows") { return false } - binding, err := localworkspace.Load() - return err == nil && binding != nil + return localworkspace.Current() != nil } diff --git a/apps/daemon/internal/agent/codex/error_classification_test.go b/apps/daemon/internal/agent/codex/error_classification_test.go index 0087f27d0..f9ccf642d 100644 --- a/apps/daemon/internal/agent/codex/error_classification_test.go +++ b/apps/daemon/internal/agent/codex/error_classification_test.go @@ -82,7 +82,7 @@ func TestClassificationOnlyFromRootTerminalError(t *testing.T) { done = true var p proto.DonePayload _ = env.DecodePayload(&p) - if p.Metadata[proto.DoneMetaAgentSessionID] != "root" || p.Usage.InputTokens != 7 { + if p.Metadata[proto.DoneMetaAgentSessionID] != "root" { t.Fatal(p) } } diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go deleted file mode 100644 index 2f6150630..000000000 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "log/slog" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// 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") - } - binary, root := os.Getenv("OAC_TEST_CODEX_BINARY"), os.Getenv("OAC_TEST_CODEX_LIVE_ROOT") - model, endpoint := os.Getenv("OAC_TEST_CODEX_MODEL"), os.Getenv("OAC_TEST_CODEX_BASE_URL") - if binary == "" || root == "" || model == "" || endpoint == "" { - t.Fatal("missing explicit native acceptance configuration") - } - version, err := exec.Command(binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("native artifact is not Codex 0.153.4") - } - key, err := os.ReadFile(os.Getenv("OAC_TEST_CODEX_KEY_FILE")) - if err != nil || strings.TrimSpace(string(key)) == "" { - t.Fatal("private provider credential unavailable") - } - if err = os.MkdirAll(root, 0700); err != nil { - t.Fatal("cannot create isolated acceptance root") - } - isolated, err := os.MkdirTemp(root, "run-") - if err != nil { - t.Fatal("cannot create isolated acceptance workspace") - } - t.Cleanup(func() { _ = os.RemoveAll(isolated) }) - t.Setenv("OAC_RUNTIME_HOME", isolated) - 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, - 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) - defer cancel() - began := time.Now() - e, err := newExecutor(ctx, req, cfg) - if err != nil { - t.Fatalf("native prepare failed (%T)", err) - } - t.Cleanup(func() { - c, stop := context.WithTimeout(context.Background(), 20*time.Second) - defer stop() - if err := e.Close(c); err != nil { - t.Errorf("native owner cleanup failed (%T)", err) - } - }) - t.Logf("native_version=0.153.4 prepare_ms=%d", time.Since(began).Milliseconds()) - pid := e.prepared.session.rpc.cmd.Process.Pid - var thread string - run := func(id, prompt string, old agent.Turn, interrupt bool) (agent.Turn, string) { - t.Helper() - output := make(chan proto.Envelope, 512) - started := time.Now() - turn, err := e.StartTurn(ctx, id, proto.TextInput(prompt), output) - if err != nil { - t.Fatalf("%s start failed (%T)", id, err) - } - if old != nil { - if err := old.Cancel(ctx); err != nil { - t.Fatalf("%s stale cancellation failed", id) - } - } - done := make(chan struct{}) - called := make(chan struct{}, 1) - var text string - var terminal int - var first time.Duration - go func() { - defer close(done) - for event := range output { - if first == 0 { - first = time.Since(started) - } - if event.Type == proto.TypeFunctionCall { - select { - case called <- struct{}{}: - default: - } - } - if event.Type == proto.TypeDone { - var result proto.DonePayload - _ = event.DecodePayload(&result) - text = result.Content - terminal++ - } - } - }() - if interrupt { - select { - case <-called: - case <-done: - t.Fatal("native cancellation did not reach its function") - case <-ctx.Done(): - t.Fatal("native function admission timed out") - } - cancelStarted := time.Now() - if err := turn.Cancel(ctx); err != nil { - t.Fatalf("native interrupt failed (%T)", err) - } - t.Logf("turn=%s cancel_settled_ms=%d", id, time.Since(cancelStarted).Milliseconds()) - } - settlement, err := turn.AwaitSettlement(ctx) - if err != nil || !settlement.Reusable { - t.Fatalf("%s native settlement not reusable (error=%t reason=%s)", id, err != nil, settlement.Reason) - } - <-done - if terminal != 1 { - t.Fatalf("%s emitted %d terminals", id, terminal) - } - s := turn.(*Session) - if thread == "" { - thread = s.currentThreadID() - } - if thread == "" || s.currentThreadID() != thread || !s.rpc.Alive() || s.rpc.cmd.Process.Pid != pid { - t.Fatal("native owner/thread changed") - } - t.Logf("turn=%s first_event_ms=%d settled_ms=%d same_process=true same_thread=true", id, first.Milliseconds(), time.Since(started).Milliseconds()) - return turn, text - } - first, _ := run("cold", "Remember the exact code CEDAR-4729 for this conversation. Reply only SAVED. Do not call tools.", nil, false) - _, answer := run("warm", "What is the exact code I asked you to remember? Reply only the code. Do not call tools.", nil, false) - if strings.TrimSpace(answer) != "CEDAR-4729" { - t.Fatal("warm native Turn did not retain conversation memory") - } - cancelled, _ := run("cancel", "Call the hold function now with an empty object. Wait for its result before responding.", nil, true) - _, answer = run("followup", "What is the exact code I asked you to remember? Reply only the code. Do not call tools.", cancelled, false) - if strings.TrimSpace(answer) != "CEDAR-4729" { - t.Fatal("post-cancellation native Turn lost conversation memory") - } - if err := first.Cancel(ctx); err != nil { - t.Fatal("retired first Turn cancellation failed") - } - if err := e.Close(ctx); err != nil { - t.Fatal("native final cleanup failed") - } - if e.prepared.session.rpc.Alive() { - t.Fatal("native child remained alive after owner close") - } -} diff --git a/apps/daemon/internal/agent/codex/installation.go b/apps/daemon/internal/agent/codex/installation.go index fe8e645b5..70b2e1d09 100644 --- a/apps/daemon/internal/agent/codex/installation.go +++ b/apps/daemon/internal/agent/codex/installation.go @@ -5,10 +5,14 @@ import ( "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" "path/filepath" "runtime" ) +// versionOutput is what the pinned native binary prints for --version. +var versionOutput = "codex-cli " + declaration.Declaration.Native.Version + func Installation() agent.Installation { binary := func(dir string) string { name := "codex" @@ -17,13 +21,13 @@ func Installation() agent.Installation { } return filepath.Join(dir, "bin", name) } - return agent.Installation{AgentKind: "codex", Version: "0.153.4", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + return agent.Installation{AgentKind: declaration.Declaration.Kind, Version: declaration.Declaration.Native.Version, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, Environment: func(dir, node string) map[string]string { - return map[string]string{"OAC_RUNTIME_CODEX_BIN": binary(dir)} + return map[string]string{binaryEnv: binary(dir)} }, Check: func(ctx context.Context, dir, node string, env []string) error { got, err := installroot.Probe(ctx, binary(dir), []string{"--version"}, env, dir) - if err != nil || got != "codex-cli 0.153.4" { + if err != nil || got != versionOutput { return fmt.Errorf("Codex installation is incompatible") } return nil diff --git a/apps/daemon/internal/agent/codex/mcp_environment_test.go b/apps/daemon/internal/agent/codex/mcp_environment_test.go index 147a2e7c3..1ab899d43 100644 --- a/apps/daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/daemon/internal/agent/codex/mcp_environment_test.go @@ -8,14 +8,13 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { token := "user-token" 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}"}}}, + {InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/0", Server: proto.PluginMCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, + {InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/1", BearerToken: &token, Server: proto.PluginMCPServer{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) != 1 { @@ -58,7 +57,7 @@ func TestSessionMCPHeadersReachNativeConfig(t *testing.T) { func TestEnvironmentMCPRejectsUnqualifiedCredentialChanges(t *testing.T) { token := "user-token" - local := &proto.LocalEnvironment{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: proto.PluginMCPServer{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/options.go b/apps/daemon/internal/agent/codex/options.go index 4a696d994..c3096e13f 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -53,9 +53,6 @@ type SessionPlan struct { // SystemPrompt is forwarded as developerInstructions on thread/start. SystemPrompt string - // ModelReasoningEffort is frozen for launch and every native Turn. - ModelReasoningEffort string - // ReasoningSummary reports that the Agent requested reasoning summaries. ReasoningSummary bool @@ -207,7 +204,6 @@ func applyExecutionControls(plan *SessionPlan, controls *proto.ExecutionControls plan.OutputSchema = format.Schema } if controls.ReasoningEffort != "" { - plan.ModelReasoningEffort = controls.ReasoningEffort set("model_reasoning_effort", strconv(controls.ReasoningEffort)) } if controls.ReasoningSummary != "" { diff --git a/apps/daemon/internal/agent/codex/permission_profile_test.go b/apps/daemon/internal/agent/codex/permission_profile_test.go index 9ff776baf..722bda5bd 100644 --- a/apps/daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/daemon/internal/agent/codex/permission_profile_test.go @@ -31,17 +31,11 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - workspace := filepath.Join(root, "workspace") - if err := os.Mkdir(workspace, 0700); err != nil { - t.Fatal(err) - } config := filepath.Join(root, "tool-env.json") 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_TOOL_ENV_FILE": config} { - t.Setenv(key, value) - } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", config) req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{}} plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{stateRoot: testStateRoot(t)}) if err != nil { diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index 18d031f80..b75c8b23c 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -1,12 +1,7 @@ package codex -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/google/uuid" "os" "path/filepath" "testing" @@ -14,8 +9,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/google/uuid" ) type preparationWireSender chan proto.Envelope @@ -36,26 +34,18 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", filepath.Join(t.TempDir(), "capabilities")) t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") environment, session, workspace := uuid.NewString(), uuid.NewString(), filepath.Join(root, "harness") if err := os.MkdirAll(workspace, 0700); err != nil { t.Fatal(err) } - for key, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": environment, - "OAC_RUNTIME_SESSION_ID": session, - "OAC_RUNTIME_WORKSPACE": workspace, - } { - t.Setenv(key, value) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, filepath.Join(t.TempDir(), "capabilities")) if err != nil { t.Fatal(err) } req.AgentStateKey = proto.SessionStateKey(session) req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &proto.CapabilitySources{}} 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/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 2e9bd8a89..baa591ffd 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -112,14 +112,11 @@ type ThreadStartParams struct { } type Thread struct { - ID string `json:"id"` - Cwd string `json:"cwd,omitempty"` - Preview string `json:"preview,omitempty"` - UpdatedAt int64 `json:"updatedAt,omitempty"` - CreatedAt int64 `json:"createdAt,omitempty"` - Status any `json:"status,omitempty"` - Path string `json:"path,omitempty"` - CLIVersion string `json:"cliVersion,omitempty"` + ID string `json:"id"` + Cwd string `json:"cwd,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + Status any `json:"status,omitempty"` + Path string `json:"path,omitempty"` } type ThreadStartResult struct { @@ -144,9 +141,8 @@ type ThreadResumeParams struct { type UserInputType string const ( - UserInputText UserInputType = "text" - UserInputLocalImage UserInputType = "localImage" - UserInputRemoteImg UserInputType = "image" + UserInputText UserInputType = "text" + UserInputRemoteImg UserInputType = "image" ) // UserInput is one element of TurnStartParams.Input. Each variant uses a @@ -157,8 +153,6 @@ type UserInput struct { // Text variant Text string `json:"text,omitempty"` TextElements []any `json:"text_elements,omitempty"` - // LocalImage variant - Path string `json:"path,omitempty"` // Image (remote URL) variant URL string `json:"url,omitempty"` } @@ -197,9 +191,8 @@ type Turn struct { // CodexErrorInfo is a native enum with string and object variants. type TurnError struct { - Message string `json:"message,omitempty"` - CodexErrorInfo json.RawMessage `json:"codexErrorInfo,omitempty"` - AdditionalDetails *string `json:"additionalDetails,omitempty"` + Message string `json:"message,omitempty"` + CodexErrorInfo json.RawMessage `json:"codexErrorInfo,omitempty"` } // --------------------------------------------------------------------------- @@ -247,10 +240,6 @@ type ThreadItem struct { // live as constants in session.go's handler registration). // --------------------------------------------------------------------------- -type ThreadStartedNotification struct { - Thread Thread `json:"thread"` -} - type TurnStartedNotification struct { ThreadID string `json:"threadId"` Turn Turn `json:"turn"` @@ -262,17 +251,15 @@ type TurnCompletedNotification struct { } type ItemStartedNotification struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Item ThreadItem `json:"item"` - StartedAtMs int64 `json:"startedAtMs,omitempty"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item ThreadItem `json:"item"` } type ItemCompletedNotification struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Item ThreadItem `json:"item"` - CompletedAtMs int64 `json:"completedAtMs,omitempty"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item ThreadItem `json:"item"` } type AgentMessageDeltaNotification struct { diff --git a/apps/daemon/internal/agent/codex/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go index 8bd145254..1484c27b8 100644 --- a/apps/daemon/internal/agent/codex/provider_config.go +++ b/apps/daemon/internal/agent/codex/provider_config.go @@ -25,9 +25,6 @@ const providerAPIKeyEnv = "OAC_CODEX_PROVIDER_API_KEY" // ModelProviderInfo enum (model-provider-info/src/lib.rs) so the // rendered TOML deserialises 1:1 against upstream. type providerConfig struct { - // Name is the human-readable label shown in codex UI; defaults to - // "OpenAgentCore" when empty. - Name string // BaseURL is the model provider's HTTPS endpoint, including any // path prefix (e.g. /v1). Required. BaseURL string @@ -75,12 +72,8 @@ func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { b.WriteString(oacProviderSlug) b.WriteString("]\n") - name := strings.TrimSpace(cfg.Name) - if name == "" { - name = "OpenAgentCore" - } b.WriteString("name = ") - b.WriteString(tomlQuoteString(name)) + b.WriteString(tomlQuoteString("OpenAgentCore")) b.WriteByte('\n') b.WriteString("base_url = ") diff --git a/apps/daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index 6be2d4d83..03ae2512a 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -54,24 +54,18 @@ func TestWriteCodexProviderConfig_PinsResponsesWire(t *testing.T) { func TestWriteCodexProviderConfig_FullProvider(t *testing.T) { dir := t.TempDir() cfg := providerConfig{ - Name: "mygw", BaseURL: "https://platform-api.example.com/v1", BearerToken: "sk-test-fixture", + HTTPHeaders: map[string]string{"X-Request-ID": "fixture", "X-Sub-Module": "fixture"}, + QueryParams: map[string]string{"api-version": "2025-04-01-preview"}, WireAPI: "responses", - HTTPHeaders: map[string]string{ - "X-Sub-Module": "codex-internal", - "X-Request-ID": "abc", - }, - QueryParams: map[string]string{ - "api-version": "2025-04-01-preview", - }, } if err := writeCodexProviderConfig(dir, cfg); err != nil { t.Fatalf("write: %v", err) } body := mustReadFile(t, filepath.Join(dir, "config.toml")) for _, want := range []string{ - `name = "mygw"`, + `name = "OpenAgentCore"`, `base_url = "https://platform-api.example.com/v1"`, `env_key = "OAC_CODEX_PROVIDER_API_KEY"`, `[model_providers.oac.env_http_headers]`, diff --git a/apps/daemon/internal/agent/codex/recovery.go b/apps/daemon/internal/agent/codex/recovery.go index 1f787cf25..97d0df57e 100644 --- a/apps/daemon/internal/agent/codex/recovery.go +++ b/apps/daemon/internal/agent/codex/recovery.go @@ -9,7 +9,7 @@ import ( ) func SupportsNativeSessionRecovery(version string) bool { - return strings.TrimSpace(version) == "codex-cli 0.153.4" + return strings.TrimSpace(version) == versionOutput } func (s *Session) recoverRoot(plan SessionPlan) (string, error) { diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index f859c93e4..82ced82cf 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/google/uuid" @@ -158,7 +157,7 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { t.Fatal(err) } req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}, WorkspaceRoot: cwd} + req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: &proto.CapabilitySources{}, WorkspaceRoot: cwd} } e, err := testExecutor(t, "complete", req, cfg) if err != nil { diff --git a/apps/daemon/internal/agent/codex/resume_test.go b/apps/daemon/internal/agent/codex/resume_test.go index 698eb2cc7..b89149c6f 100644 --- a/apps/daemon/internal/agent/codex/resume_test.go +++ b/apps/daemon/internal/agent/codex/resume_test.go @@ -51,9 +51,9 @@ func TestCancellationKeepsConsumedTerminalOutputAndUsage(t *testing.T) { s.setThreadID("native") s.onTurnStarted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn"}}`)) s.appendFinalText("Already produced") - s.onTurnCompleted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn","status":"interrupted","usage":{"inputTokens":31,"outputTokens":7}}}`)) + s.onTurnCompleted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn","status":"interrupted","usage":{"inputTokens":31,"cachedInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0,"totalTokens":38}}}`)) snapshot := s.CancellationOutcome() - if snapshot.Content != "Already produced" || snapshot.Usage.InputTokens != 31 || snapshot.Usage.OutputTokens != 7 || snapshot.Metadata[proto.DoneMetaAgentSessionID] != "native" { + if snapshot.Content != "Already produced" || snapshot.Usage.Tokens.InputTokens != 31 || snapshot.Usage.Tokens.OutputTokens != 7 || snapshot.Metadata[proto.DoneMetaAgentSessionID] != "native" { t.Fatalf("incomplete terminal snapshot: %+v", snapshot) } } diff --git a/apps/daemon/internal/agent/codex/rpc.go b/apps/daemon/internal/agent/codex/rpc.go index 61cbae743..f812c4344 100644 --- a/apps/daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -87,9 +87,8 @@ type JSONRPCClient struct { stdout io.ReadCloser stderr io.ReadCloser - mu sync.Mutex - alive bool - exitCode *int + mu sync.Mutex + alive bool pendingMu sync.Mutex pending map[string]*pendingRequest @@ -473,10 +472,6 @@ func (c *JSONRPCClient) waitChild() { err := c.process.Wait() c.mu.Lock() c.alive = false - if c.cmd.ProcessState != nil { - code := c.cmd.ProcessState.ExitCode() - c.exitCode = &code - } c.mu.Unlock() _ = c.drainPending(fmt.Errorf("codex app-server exited: %v", err)) } diff --git a/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go b/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go index 516f9bbb6..c14d7cd98 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go @@ -109,11 +109,11 @@ func TestRootRPCNotificationOrdering(t *testing.T) { if err := env.DecodePayload(&done); err != nil { t.Fatal(err) } - wantInput := int32(130) + wantInput := int64(130) if resume { wantInput = 30 } - if done.Content != "root answer" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.InputTokens != wantInput { + if done.Content != "root answer" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.Tokens.InputTokens != wantInput { t.Fatalf("reply ordering lost root output/usage: %+v", done) } } diff --git a/apps/daemon/internal/agent/codex/session_usage.go b/apps/daemon/internal/agent/codex/session_usage.go index 4d2ea1d5d..3a2581037 100644 --- a/apps/daemon/internal/agent/codex/session_usage.go +++ b/apps/daemon/internal/agent/codex/session_usage.go @@ -115,7 +115,7 @@ func (u TurnUsage) completeTokens() bool { } func (s *Session) usagePayload(u TurnUsage) proto.Usage { - result := proto.Usage{Provider: "openai", Model: s.resolvedModel, InputTokens: int32(u.InputTokens), OutputTokens: int32(u.OutputTokens)} + result := proto.Usage{Provider: "openai", Model: s.resolvedModel} if u.completeTokens() { result.Tokens = &proto.TokenUsage{InputTokens: int64(u.InputTokens), OutputTokens: int64(u.OutputTokens), CachedInputTokens: int64(u.CachedInputTokens), ReasoningOutputTokens: int64(u.ReasoningOutputTokens), TotalTokens: int64(u.TotalTokens)} } diff --git a/apps/daemon/internal/agent/codex/session_usage_test.go b/apps/daemon/internal/agent/codex/session_usage_test.go index b75faf625..7c5e17ada 100644 --- a/apps/daemon/internal/agent/codex/session_usage_test.go +++ b/apps/daemon/internal/agent/codex/session_usage_test.go @@ -16,15 +16,15 @@ func TestNativeTokenUsageAcrossRuns(t *testing.T) { cfg: sessionConfig{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}} s.setThreadID("thread") // A resumed thread replays its previous usage before starting this run. - s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"previous","tokenUsage":{"total":{"inputTokens":1000,"outputTokens":100},"last":{"inputTokens":500,"outputTokens":50}}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"previous","tokenUsage":{"total":{"inputTokens":1000,"cachedInputTokens":0,"outputTokens":100,"reasoningOutputTokens":0,"totalTokens":1100},"last":{"inputTokens":500,"cachedInputTokens":0,"outputTokens":50,"reasoningOutputTokens":0,"totalTokens":550}}}`)) if s.latestUsage != nil { t.Fatal("restored history was treated as current usage") } s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"current"}}`)) - s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":1200,"outputTokens":120},"last":{"inputTokens":200,"outputTokens":20}}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":1200,"cachedInputTokens":0,"outputTokens":120,"reasoningOutputTokens":0,"totalTokens":1320},"last":{"inputTokens":200,"cachedInputTokens":0,"outputTokens":20,"reasoningOutputTokens":0,"totalTokens":220}}}`)) // The second model request follows a tool call; its last counter is not // the whole turn. Repeating the cumulative snapshot must not add usage. - second := json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":1500,"outputTokens":150},"last":{"inputTokens":300,"outputTokens":30}}}`) + second := json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":1500,"cachedInputTokens":0,"outputTokens":150,"reasoningOutputTokens":0,"totalTokens":1650},"last":{"inputTokens":300,"cachedInputTokens":0,"outputTokens":30,"reasoningOutputTokens":0,"totalTokens":330}}}`) s.onUsageUpdated(second) s.onUsageUpdated(second) if s.latestUsage == nil || s.latestUsage.InputTokens != 500 || s.latestUsage.OutputTokens != 50 { @@ -39,7 +39,7 @@ func TestNativeTokenUsageAcrossRuns(t *testing.T) { if s.latestUsage != nil { t.Fatal("new turn retained previous turn usage") } - s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"next","tokenUsage":{"total":{"inputTokens":1700,"outputTokens":175},"last":{"inputTokens":200,"outputTokens":25}}}`)) + s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"next","tokenUsage":{"total":{"inputTokens":1700,"cachedInputTokens":0,"outputTokens":175,"reasoningOutputTokens":0,"totalTokens":1875},"last":{"inputTokens":200,"cachedInputTokens":0,"outputTokens":25,"reasoningOutputTokens":0,"totalTokens":225}}}`)) s.finalText = "done" s.onTurnCompleted(json.RawMessage(`{"threadId":"thread","turn":{"id":"next","status":"completed"}}`)) var usage proto.UsagePayload @@ -56,7 +56,7 @@ func TestNativeTokenUsageAcrossRuns(t *testing.T) { } } } - if usage.InputTokens != 200 || usage.OutputTokens != 25 || done.Usage.InputTokens != 200 || done.Content != "done" { + if usage.Tokens.InputTokens != 200 || usage.Tokens.OutputTokens != 25 || done.Usage.Tokens.InputTokens != 200 || done.Content != "done" { t.Fatalf("terminal usage=%+v done=%+v", usage, done) } } diff --git a/apps/daemon/internal/agent/codex/subagent_rollout.go b/apps/daemon/internal/agent/codex/subagent_rollout.go index 20e063c2a..d695da7a8 100644 --- a/apps/daemon/internal/agent/codex/subagent_rollout.go +++ b/apps/daemon/internal/agent/codex/subagent_rollout.go @@ -14,7 +14,6 @@ import ( type subagentEffect struct { proto.SubagentLifecyclePayload - source, turn, call string } type rolloutCall struct { Name string `json:"name"` @@ -228,7 +227,7 @@ func readSubagentEffects(home string, h *subagentHistory) ([]subagentEffect, err if json.Unmarshal(output, &text) != nil || json.Unmarshal([]byte(text), &receipt) != nil || len(receipt) != 1 || !validSubagentStatus(receipt[key]) { return nil, errors.New("codex: lifecycle success receipt unavailable") } - effects = append(effects, subagentEffect{proto.SubagentLifecyclePayload{NativeID: target, EffectID: h.ID + ":" + item.ID, Status: status, OccurredAtMS: completion.CompletedAt}, h.ID, turn.ID, item.ID}) + effects = append(effects, subagentEffect{proto.SubagentLifecyclePayload{NativeID: target, EffectID: h.ID + ":" + item.ID, Status: status, OccurredAtMS: completion.CompletedAt}}) } } for call := range calls { diff --git a/apps/daemon/internal/agent/codex/subagent_tools_native_test.go b/apps/daemon/internal/agent/codex/subagent_tools_native_test.go deleted file mode 100644 index 4d0840434..000000000 --- a/apps/daemon/internal/agent/codex/subagent_tools_native_test.go +++ /dev/null @@ -1,131 +0,0 @@ -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/unreachable_model_native_test.go b/apps/daemon/internal/agent/codex/unreachable_model_native_test.go deleted file mode 100644 index c3bbc164f..000000000 --- a/apps/daemon/internal/agent/codex/unreachable_model_native_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package codex - -import ( - "context" - "log/slog" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// A model host that does not resolve must fail the Turn with -// connection_failed instead of retrying forever. Opt-in: it needs the pinned -// native binary but no credentials or network. -func TestExecutorNativeUnreachableModelHost(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) - req := proto.PromptRequestPayload{ - AgentKind: "codex", AgentStateKey: "unreachable-model-host", - DisableExecutionEnvironment: true, DisableSubagents: true, - AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"base_url": "https://model-host.invalid/v1", "protocol": "responses", "api_key": "unused"}}, - } - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) - defer cancel() - e, err := newExecutor(ctx, req, cfg) - if err != nil { - t.Fatalf("native prepare failed: %v", err) - } - t.Cleanup(func() { - c, stop := context.WithTimeout(context.Background(), 20*time.Second) - defer stop() - _ = e.Close(c) - }) - output := make(chan proto.Envelope, 64) - began := time.Now() - turn, err := e.StartTurn(ctx, "unreachable", proto.TextInput("Reply OK."), output) - if err != nil { - t.Fatalf("start failed: %v", err) - } - var failure proto.ErrorPayload - for done := false; !done; { - select { - case event := <-output: - t.Logf("+%s %s", time.Since(began).Round(time.Millisecond), event.Type) - if event.Type == proto.TypeError { - _ = event.DecodePayload(&failure) - } - done = event.Type == proto.TypeDone - case <-ctx.Done(): - t.Fatalf("Turn did not settle within %s", time.Since(began).Round(time.Second)) - } - } - _, _ = turn.AwaitSettlement(ctx) - t.Logf("failed after %s with %q", time.Since(began).Round(time.Millisecond), failure.Code) - if failure.Code != "connection_failed" { - t.Fatalf("failure code = %q, want connection_failed", failure.Code) - } -} diff --git a/apps/daemon/internal/agent/codex/unsupported.go b/apps/daemon/internal/agent/codex/unsupported.go deleted file mode 100644 index 3da26fb7f..000000000 --- a/apps/daemon/internal/agent/codex/unsupported.go +++ /dev/null @@ -1,30 +0,0 @@ -package codex - -import ( - "context" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func (s *Executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Executor) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} - -func (s *Session) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/codex/unsupported_test.go b/apps/daemon/internal/agent/codex/unsupported_test.go deleted file mode 100644 index fe2fe3616..000000000 --- a/apps/daemon/internal/agent/codex/unsupported_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package codex - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -// Nil concrete receivers prove Unsupported needs no native owner, transport, -// workspace access or input retention. Callers still preserve the separate -// nil-Turn ownership rule on required lifecycle methods. -func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { - ctx := context.Background() - const secret = "private-fixture-value" - check := func(err error) { - t.Helper() - if !errors.Is(err, agent.ErrUnsupportedOperation) || err.Error() == agent.ErrUnsupportedOperation.Error() { - t.Fatalf("expected explicit unsupported result with reason, got %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatal("unsupported error disclosed input") - } - } - for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil)} { - result, err := owner.ReadWorkspaceFile(ctx, secret, 1) - check(err) - if len(result.Data) != 0 || result.Truncated { - t.Fatal("unsupported read fabricated data") - } - } - for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil)} { - result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) - check(err) - if len(result.Entries) != 0 || result.Truncated { - t.Fatal("unsupported listing fabricated entries") - } - } - for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil)} { - result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) - check(err) - if result.SizeBytes != 0 { - t.Fatal("unsupported write fabricated receipt") - } - } -} diff --git a/apps/daemon/internal/agent/codex/version.go b/apps/daemon/internal/agent/codex/version.go index 432c8399f..6cae79d20 100644 --- a/apps/daemon/internal/agent/codex/version.go +++ b/apps/daemon/internal/agent/codex/version.go @@ -5,11 +5,11 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) @@ -17,11 +17,18 @@ import ( // the daemon can see the adapter but not the CLI binary. const InstallURL = "https://github.com/openai/codex" -// defaultBinary is the executable to probe and spawn: binpath.Codex() -// honours the OAC_RUNTIME_CODEX_BIN override so a bare-name PATH lookup can -// be bypassed in images where PATH is not under our control. A function -// rather than a const so the env is read at call time. -func defaultBinary() string { return binpath.Codex() } +// binaryEnv pins the codex executable in images where PATH is not under our +// control, so the version probe and the run-time spawn resolve the same binary. +const binaryEnv = "OAC_RUNTIME_CODEX_BIN" + +// defaultBinary is the executable to probe and spawn: the trimmed binaryEnv +// override, else "codex" on PATH. Read at call time. +func defaultBinary() string { + if v := strings.TrimSpace(os.Getenv(binaryEnv)); v != "" { + return v + } + return "codex" +} // ErrCLINotFound is returned by CheckCLIAvailable when the binary // cannot be located on PATH. Callers use errors.Is to distinguish an diff --git a/apps/daemon/internal/agent/codex/workspace_trust_native_test.go b/apps/daemon/internal/agent/codex/workspace_trust_native_test.go deleted file mode 100644 index 63ffeb1bb..000000000 --- a/apps/daemon/internal/agent/codex/workspace_trust_native_test.go +++ /dev/null @@ -1,135 +0,0 @@ -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 bae330dbc..ef12a649e 100644 --- a/apps/daemon/internal/agent/contract_declarations_test.go +++ b/apps/daemon/internal/agent/contract_declarations_test.go @@ -14,14 +14,11 @@ import ( // decision. Each adapter's compile assertions then enforce its actual methods. func TestPublicHarnessContractDeclarations(t *testing.T) { roles := map[string][]string{ - "Executor": {"executor"}, - "Turn": {"session"}, - "Session": {"session"}, - "DurableSteerer": {"session"}, - "FunctionResultSubmitter": {"session"}, - "WorkspaceReader": {"executor", "session"}, - "WorkspaceDirectoryLister": {"executor", "session"}, - "WorkspaceWriter": {"executor", "session"}, + "Executor": {"executor"}, + "Turn": {"session"}, + "Session": {"session"}, + "DurableSteerer": {"session"}, + "FunctionResultSubmitter": {"session"}, } files, err := filepath.Glob("*.go") if err != nil { diff --git a/apps/daemon/internal/agent/contracttest/text.go b/apps/daemon/internal/agent/contracttest/text.go index bc7f336a7..90e513753 100644 --- a/apps/daemon/internal/agent/contracttest/text.go +++ b/apps/daemon/internal/agent/contracttest/text.go @@ -66,12 +66,8 @@ func TextLifecycle(t *testing.T, fixture TextFixture) { if err := first.Cancel(ctx); err != nil { t.Fatal(err) } - steerer, ok := second.(agent.DurableSteerer) - if !ok { - 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}, func() { written.Add(1) }); err != nil { + if err := second.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 { diff --git a/apps/daemon/internal/agent/functions.go b/apps/daemon/internal/agent/functions.go deleted file mode 100644 index c34d8b32c..000000000 --- a/apps/daemon/internal/agent/functions.go +++ /dev/null @@ -1,7 +0,0 @@ -package agent - -import ( - "errors" -) - -var ErrUnknownFunctionCall = errors.New("agent: function call is no longer pending") diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 19f2364df..25b5ff61e 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -28,6 +28,7 @@ package agent import ( "context" + "errors" "io" "reflect" "strings" @@ -145,6 +146,7 @@ type Executor interface { type Turn interface { Session DurableSteerer + FunctionResultSubmitter // Success confirms closed output and settled native input, function and // child-work obligations. Errors cannot prove cancellation. AwaitSettlement(context.Context) (TurnSettlement, error) @@ -182,39 +184,40 @@ type DurableSteerer interface { SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error } -// FunctionResultSubmitter delivers a result for an outstanding native call. +// FunctionResultSubmitter is mandatory on every public Turn; support comes from the declaration. It delivers a result for an outstanding native call. // A successful return requires its native application receipt. type FunctionResultSubmitter interface { SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error } -// Workspace extensions, implemented by each owner explicitly. The common -// Runtime may supply an authorized workspace owner independently of the adapter. -// A resource without native access returns the corresponding Unsupported error; -// this does not disable capabilities provided by the common workspace owner. +// Protocol errors. -// WorkspaceReader returns success only after acknowledged native close on an existing owner. -type WorkspaceReader interface { - ReadWorkspaceFile(context.Context, string, int) (WorkspaceReadResult, error) -} +// ErrUnsupportedOperation marks an intentional absence of optional behavior. +// Implementations wrap it with a static reason and perform no native effects. +// It is distinct from unavailable resources, failures and uncertain outcomes. +var ErrUnsupportedOperation = errors.New("agent: unsupported operation") -// WorkspaceDirectoryLister reads one directory through an existing workspace owner. -// An empty directory selects the root; other paths contain only relative components. -// Entry names are single components. Kind is file, directory, symlink or other; -// SizeBytes is present and nonnegative only for regular files. Results have no -// prescribed order, and Truncated must not be presented as a complete inventory. -// maxEntries is positive; adapters may reject limits above their private bound. -// Successful return requires settled directory/metadata access and handle cleanup. -// Implementations retain workspace authorization and isolation and use the existing -// WorkspaceRead errors for unsupported, unavailable, busy, invalid or uncertain reads. -// This interface does not establish public Files pagination or feature admission. -type WorkspaceDirectoryLister interface { - ListWorkspaceDirectory(context.Context, string, int) (WorkspaceDirectoryResult, error) -} +// ErrUnknownFunctionCall means the function call is no longer pending. +var ErrUnknownFunctionCall = errors.New("agent: function call is no longer pending") + +// ErrSteeringNotReady means no input was sent because the turn is starting. +var ErrSteeringNotReady = errors.New("agent: turn is not ready for input") + +// ErrSteeringInactive means no input was sent because the run ended or was cancelled. +var ErrSteeringInactive = errors.New("agent: run is no longer active") + +// ErrSteeringRejected means the engine explicitly rejected the input. +var ErrSteeringRejected = errors.New("agent: input rejected") -// WorkspaceWriter confirms a native commit on an already authorized prepared owner. -type WorkspaceWriter interface { - WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) +// Installation is an optional adapter-owned native distribution contract. +// It supplies activation paths and readiness checks, never execution or model +// configuration. Runtime owns copying, checksums, publication and installation locks. +type Installation struct { + AgentKind string + Version string + Supported func() bool + Environment func(directory, node string) map[string]string + Check func(context.Context, string, string, []string) error } // Kind registration. diff --git a/apps/daemon/internal/agent/installation.go b/apps/daemon/internal/agent/installation.go deleted file mode 100644 index 2083b8052..000000000 --- a/apps/daemon/internal/agent/installation.go +++ /dev/null @@ -1,14 +0,0 @@ -package agent - -import "context" - -// Installation is an optional adapter-owned native distribution contract. -// It supplies activation paths and readiness checks, never execution or model -// configuration. Runtime owns copying, checksums, publication and installation locks. -type Installation struct { - AgentKind string - Version string - Supported func() bool - Environment func(directory, node string) map[string]string - Check func(context.Context, string, string, []string) error -} diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go index 30e32fa6f..c59b7eb70 100644 --- a/apps/daemon/internal/agent/mcode/contracts.go +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -5,15 +5,9 @@ import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Every public Harness implements each small contract explicitly. Unsupported // extensions return agent.ErrUnsupportedOperation without native effects. var ( - _ agent.Executor = (*executor)(nil) - _ agent.Turn = (*Session)(nil) - _ agent.Session = (*Session)(nil) - _ agent.DurableSteerer = (*Session)(nil) - _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.WorkspaceReader = (*Session)(nil) - _ agent.WorkspaceDirectoryLister = (*Session)(nil) - _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.WorkspaceReader = (*executor)(nil) - _ agent.WorkspaceDirectoryLister = (*executor)(nil) - _ agent.WorkspaceWriter = (*executor)(nil) + _ agent.Executor = (*executor)(nil) + _ agent.Turn = (*Session)(nil) + _ agent.Session = (*Session)(nil) + _ agent.DurableSteerer = (*Session)(nil) + _ agent.FunctionResultSubmitter = (*Session)(nil) ) diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go index d8f60f021..b213c9054 100644 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -8,7 +8,6 @@ import ( "path/filepath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -18,20 +17,16 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r *runtime = *agent.Unavailable(runtime.Info) fmt.Fprintf(options.Stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) } - binding, err := localworkspace.Load() - if err != nil { - fail(err) - return nil - } + binding := localworkspace.Current() if binding == nil { narrowWorkspace(&runtime.Info.Capabilities, false) return nil } - node := os.Getenv("OAC_RUNTIME_MCODE_NODE") + node := runtimeNode() if node == "" { node = "node" } - node, err = exec.LookPath(node) + node, err := exec.LookPath(node) if err != nil { fail(err) return nil @@ -41,7 +36,7 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r fail(err) return nil } - binary, err := exec.LookPath(binpath.MCode()) + binary, err := exec.LookPath(defaultBinary()) if err != nil { fail(err) return nil @@ -51,7 +46,7 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r fail(err) return nil } - c, err := ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_COMPANION"), os.Getenv("OAC_RUNTIME_WORKSPACE")) + c, err := ConfigureLocal(binary, node, os.Getenv(companionEnv), binding.Workspace()) if err == nil { err = CheckWorkspace(parent, c) } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index 677d6ced2..eb26734af 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -11,11 +11,10 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func environmentMCPFixture() proto.EnvironmentMCP { - return proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ + return proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/fixture", Server: proto.PluginMCPServer{ Name: "proof.server", Type: "stdio", Command: "never-exec-before-sandbox", Args: []string{"private-argument"}, EnvVars: []string{"USER_SELECTED"}, CWD: "resources", }} @@ -77,7 +76,7 @@ func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} switch name { case "http-bearer-insecure", "http-bearer-missing": - req.LocalEnvironment.MCP[0].Server = agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"} + req.LocalEnvironment.MCP[0].Server = proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"} if name == "http-bearer-missing" { req.LocalEnvironment.MCP[0].Server.BearerTokenEnvVar = "SELECTED_TOKEN" } @@ -177,7 +176,7 @@ 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) - item := proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}} + item := proto.EnvironmentMCP{Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}} const token = "private-mcp-canary" if authenticated { value := token diff --git a/apps/daemon/internal/agent/mcode/installation.go b/apps/daemon/internal/agent/mcode/installation.go index ce35f0f42..d6af008a0 100644 --- a/apps/daemon/internal/agent/mcode/installation.go +++ b/apps/daemon/internal/agent/mcode/installation.go @@ -5,14 +5,15 @@ import ( "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" "path/filepath" "runtime" ) func Installation() agent.Installation { - return agent.Installation{AgentKind: "mcode", Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, + return agent.Installation{AgentKind: declaration.Declaration.Kind, 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_COMPANION": dir} + return map[string]string{binaryEnv: filepath.Join(dir, "native", "cli.js"), nodeEnv: node, companionEnv: 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/native_history_test.go b/apps/daemon/internal/agent/mcode/native_history_test.go deleted file mode 100644 index dc34007b5..000000000 --- a/apps/daemon/internal/agent/mcode/native_history_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "strings" - "testing" - "time" -) - -// A successful public real-model run supplies an actual foreign native ID. -// Neither that history nor a missing ID may prepare an Executor, so no Turn can -// run against it or silently replace it with a new session. -func TestNativeMCodeHistoryIsolation(t *testing.T) { - binary, options, foreign := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS"), os.Getenv("OAC_TEST_MCODE_FOREIGN_NATIVE_ID") - if binary == "" || options == "" || foreign == "" { - t.Skip("native executable, private provider options and foreign history ID required") - } - raw, err := os.ReadFile(options) - if err != nil { - t.Fatal(err) - } - for name, id := range map[string]string{"foreign": foreign, "missing": "00000000-0000-4000-8000-000000000000"} { - t.Run(name, func(t *testing.T) { - req := testRequest(t) - if json.Unmarshal(raw, &req.AgentOptions) != nil { - t.Fatal("invalid private options") - } - req.AgentSessionID = id - ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) - defer cancel() - e, err := prepareExecutor(t, ctx, req) - if e != nil || err == nil || !strings.Contains(err.Error(), "session/load:") || strings.Contains(err.Error(), "deadline exceeded") { - t.Fatal("native history was not explicitly rejected") - } - }) - } -} diff --git a/apps/daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go deleted file mode 100644 index 48b01ff6f..000000000 --- a/apps/daemon/internal/agent/mcode/native_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Opt in with the installed native CLI; the default test gate uses protocol fixtures. -func TestNativeMCodeACP(t *testing.T) { testNativeMCodeHeaders(t, "anthropic", false) } -func TestNativeMCodeChatHeaders(t *testing.T) { testNativeMCodeHeaders(t, "chat_completions", true) } -func testNativeMCodeHeaders(t *testing.T, protocol string, workspace bool) { - binary := os.Getenv("OAC_TEST_MCODE_INTEGRATION_BIN") - if binary == "" { - t.Skip("set OAC_TEST_MCODE_INTEGRATION_BIN to run native ACP smoke test") - } - req := testRequest(t) - t.Setenv("OAC_RUNTIME_MCODE_BIN", binary) - var mu sync.Mutex - var requests []string - attempts := 0 - var toolSent atomic.Bool - model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - - if r.Header.Get("X-Sandbase-Benefit") != "daily-free" || r.Header.Get("X-Literal") != "${unchanged}:value" { - t.Error("native model request lost frozen headers") - } - if strings.HasSuffix(r.URL.Path, "/input_tokens") { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"input_tokens":10}`)) - return - } - mu.Lock() - attempts++ - attempt := attempts - mu.Unlock() - if attempt == 1 { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":{"type":"api_error","message":"fixture retry"}}`)) - return - } - var body map[string]any - if json.NewDecoder(r.Body).Decode(&body) != nil { - w.WriteHeader(400) - return - } - raw, _ := json.Marshal(body) - mu.Lock() - requests = append(requests, string(raw)) - 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) - if !ok { - function = raw.(map[string]any) - } - name, _ := function["name"].(string) - 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, arguments) - } else { - writeNativeResponse(w) - } - })) - defer model.Close() - req.AgentOptions["model_provider"] = map[string]any{"protocol": protocol, "base_url": model.URL, "api_key": "fixture-only", "headers": map[string]string{"X-Sandbase-Benefit": "daily-free", "X-Literal": "${unchanged}:value"}, "context_window": 64000, "max_output_tokens": 4096} - req.AgentOptions["system_prompt"] = "SP-MCODE-672: reply as instructed." - req.Input = proto.TextInput("Reply OAC-MCODE-OK.") - - req.RunID, req.Input = "", nil - stateRoot := testStateRoot(t) - ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) - 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) - } - node, err := exec.LookPath("node") - if err != nil { - t.Fatal(err) - } - 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} - } - factory := NewExecutorFactory(stateRoot, config) - owner, err := factory(ctx, req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = owner.Close(context.Background()) }() - runNumber := 0 - run := func() proto.DonePayload { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - t.Cleanup(cancel) - out := make(chan proto.Envelope, 64) - runNumber++ - turn, err := owner.StartTurn(ctx, fmt.Sprintf("headers-%d", runNumber), proto.TextInput("Reply OAC-MCODE-OK."), out) - if err != nil { - t.Fatal(err) - } - var done proto.DonePayload - for event := range out { - if event.Type == proto.TypeError { - t.Fatalf("native ACP failure: %s", event.Payload) - } - if event.Type == proto.TypeDone { - _ = json.Unmarshal(event.Payload, &done) - } - } - if _, err = turn.AwaitSettlement(ctx); err != nil { - t.Fatal(err) - } - if done.Content != "OAC-MCODE-OK" { - t.Fatalf("native output=%q", done.Content) - } - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID].(string); !ok { - t.Fatal("native session ID missing") - } - return done - } - done := run() - run() - if err = owner.Close(ctx); err != nil { - t.Fatal(err) - } - mu.Lock() - firstRequests := strings.Join(requests, "\n") - requests = nil - mu.Unlock() - if !strings.Contains(firstRequests, "SP-MCODE-672") { - t.Fatal("native instructions missing") - } - req.RunID = "" - req.AgentSessionID = done.Metadata[proto.DoneMetaAgentSessionID].(string) - req.AgentOptions["system_prompt"] = "SP-MCODE-NEW: reply concisely." - req.AgentOptions["model"] = "fixture-new" - req.Input = nil - owner, err = factory(ctx, req) - if err != nil { - t.Fatal(err) - } - run() - mu.Lock() - resumed := strings.Join(requests, "\n") - mu.Unlock() - if !strings.Contains(resumed, "SP-MCODE-NEW") { - t.Fatal("resume retained stale instructions") - } - if !strings.Contains(resumed, `"model":"fixture-new"`) { - t.Fatal("resume did not use the updated model") - } - mu.Lock() - attemptCount := attempts - mu.Unlock() - if workspace && (!strings.Contains(firstRequests, "HEADER-TOOL-CONTINUATION") || attemptCount < 5) { - t.Fatalf("native tool continuation was not captured attempts=%d", attemptCount) - } - if attemptCount < 4 { - t.Fatal("retry and resume were not observed") - } - t.Log("native new/retry/resume model request headers verified") -} - -func writeNativeResponse(w http.ResponseWriter) { - w.Header().Set("Content-Type", "text/event-stream") - send := func(kind string, payload any) { - data, _ := json.Marshal(payload) - _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, data) - } - send("message_start", map[string]any{"type": "message_start", "message": map[string]any{"id": "msg_qa", "type": "message", "role": "assistant", "model": "fixture", "content": []any{}, "stop_reason": nil, "stop_sequence": nil, "usage": map[string]int{"input_tokens": 20, "output_tokens": 0}}}) - send("content_block_start", map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]string{"type": "text", "text": ""}}) - send("content_block_delta", map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]string{"type": "text_delta", "text": "OAC-MCODE-OK"}}) - send("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) - send("message_delta", map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "usage": map[string]int{"output_tokens": 8}}) - send("message_stop", map[string]string{"type": "message_stop"}) -} - -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": 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}} { - body, _ := json.Marshal(map[string]any{"id": "chatcmpl_fixture", "object": "chat.completion.chunk", "created": 1, "model": "fixture", "choices": []any{choice}, "usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}) - _, _ = fmt.Fprintf(w, "data: %s\n\n", body) - } - _, _ = fmt.Fprint(w, "data: [DONE]\n\n") -} diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 703fdf0d7..8fb9e5fce 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -51,9 +51,6 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload, workspac } opts := req.AgentOptions prompt := optionString(opts, "system_prompt") - if len(prompt) > 32*1024 { - return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") - } if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { return result, err } diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 989475c9b..027b5a961 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -67,7 +67,6 @@ func TestOptionsRejectDroppedContext(t *testing.T) { name string edit func(*proto.PromptRequestPayload) }{ - {"oversized instructions", func(r *proto.PromptRequestPayload) { r.AgentOptions["system_prompt"] = strings.Repeat("x", 32*1024+1) }}, {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }}, } diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index 6aadc85e9..c080aa50c 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -117,7 +117,7 @@ func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { if p.Content != "Hello world" || p.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { t.Fatalf("done = %#v", p) } - if p.Usage.InputTokens != 0 || p.Usage.OutputTokens != 0 || p.Usage.CostUSD != 0 { + if p.Usage.Tokens != nil { t.Fatalf("context occupancy reported as usage: %#v", p.Usage) } } diff --git a/apps/daemon/internal/agent/mcode/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go index 57c5a5749..61f6bc179 100644 --- a/apps/daemon/internal/agent/mcode/subagents.go +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -51,9 +51,15 @@ type nativeSubagentTask struct { Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } } +const nodeEnv = "OAC_RUNTIME_MCODE_NODE" +const companionEnv = "OAC_RUNTIME_MCODE_COMPANION" + +// runtimeNode is the Node executable the installation or image provides for the companion scripts. +func runtimeNode() string { return os.Getenv(nodeEnv) } + func subagentReader() (string, string, error) { - node := os.Getenv("OAC_RUNTIME_MCODE_NODE") - reader := filepath.Join(os.Getenv("OAC_RUNTIME_MCODE_COMPANION"), "subagent-snapshot.mjs") + node := runtimeNode() + reader := filepath.Join(os.Getenv(companionEnv), "subagent-snapshot.mjs") for _, path := range []string{node, reader} { resolved, err := filepath.EvalSymlinks(path) if err != nil || !filepath.IsAbs(path) || resolved != path { diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go index 5fa31ea5a..ad8a50b2c 100644 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -2,15 +2,15 @@ package mcode import ( "bytes" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "io/fs" "os" "path/filepath" "slices" "testing" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/google/uuid" ) @@ -34,20 +34,17 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T write(filepath.Join(plugin, ".codex-plugin", "plugin.json"), `{"name":"remote","description":"Credential fixture","mcpServers":"./.mcp.json"}`) write(filepath.Join(plugin, ".mcp.json"), `{"mcpServers":{"remote":{"type":"http","url":"https://example.invalid/mcp","bearer_token_env_var":"MCP_TEST_TOKEN"}}}`) environment, session := uuid.NewString(), uuid.NewString() - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", environment) - t.Setenv("OAC_RUNTIME_SESSION_ID", session) - t.Setenv("OAC_RUNTIME_WORKSPACE", config.Directory) - t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", t.TempDir()) + capabilityDirectory := t.TempDir() req.AgentStateKey = proto.SessionStateKey(session) req.LocalEnvironment.ID, req.LocalEnvironment.WorkspaceDirectory = environment, config.Directory req.LocalEnvironment.Capabilities = true - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{plugin}} + req.LocalEnvironment.CapabilitySources = &proto.CapabilitySources{Directories: []string{plugin}} for _, resume := range []bool{false, true} { if resume { req.AgentSessionID = "native-1" write(source, `{"MCP_TEST_TOKEN":"changed","TOOL_SECRET":"changed"}`) } - binding, err := localworkspace.Load() + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, config.Directory, capabilityDirectory) if err != nil { t.Fatal(err) } @@ -100,7 +97,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T if err := os.Remove(filepath.Join(initialization, "tool-env.json")); err != nil { t.Fatal(err) } - binding, err := localworkspace.Load() + binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, config.Directory, capabilityDirectory) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go deleted file mode 100644 index ac35c4efa..000000000 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ /dev/null @@ -1,30 +0,0 @@ -package mcode - -import ( - "context" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func (s *executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *executor) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} - -func (s *Session) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go deleted file mode 100644 index 05a2afb0d..000000000 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package mcode - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -// Nil concrete receivers prove Unsupported needs no native owner, transport, -// workspace access or input retention. Callers still preserve the separate -// nil-Turn ownership rule on required lifecycle methods. -func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { - ctx := context.Background() - const secret = "private-fixture-value" - check := func(err error) { - t.Helper() - if !errors.Is(err, agent.ErrUnsupportedOperation) || err.Error() == agent.ErrUnsupportedOperation.Error() { - t.Fatalf("expected explicit unsupported result with reason, got %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatal("unsupported error disclosed input") - } - } - for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { - result, err := owner.ReadWorkspaceFile(ctx, secret, 1) - check(err) - if len(result.Data) != 0 || result.Truncated { - t.Fatal("unsupported read fabricated data") - } - } - for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil)} { - result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) - check(err) - if len(result.Entries) != 0 || result.Truncated { - t.Fatal("unsupported listing fabricated entries") - } - } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil)} { - result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) - check(err) - if result.SizeBytes != 0 { - t.Fatal("unsupported write fabricated receipt") - } - } -} diff --git a/apps/daemon/internal/agent/mcode/version.go b/apps/daemon/internal/agent/mcode/version.go index 95c476007..42bb13389 100644 --- a/apps/daemon/internal/agent/mcode/version.go +++ b/apps/daemon/internal/agent/mcode/version.go @@ -4,16 +4,28 @@ import ( "context" "errors" "fmt" + "os" + "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) var ErrCLINotFound = errors.New("mcode CLI not found") -const SupportedVersion = "0.4.12" +var SupportedVersion = declaration.Declaration.Native.Version -func defaultBinary() string { return binpath.MCode() } +// binaryEnv pins the mcode executable in images where PATH is not under our +// control, so the version probe and the run-time spawn resolve the same binary. +const binaryEnv = "OAC_RUNTIME_MCODE_BIN" + +// defaultBinary is the trimmed binaryEnv override, else "mcode" on PATH. Read at call time. +func defaultBinary() string { + if v := strings.TrimSpace(os.Getenv(binaryEnv)); v != "" { + return v + } + return "mcode" +} func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { version, err := versionprobe.Check(ctx, binary, versionprobe.Config{Name: "mcode", DefaultBinary: defaultBinary(), MissingError: ErrCLINotFound, TrimBinary: true}) diff --git a/apps/daemon/internal/agent/mcode/workspace_skills_test.go b/apps/daemon/internal/agent/mcode/workspace_skills_test.go index c063c6553..da9ff12eb 100644 --- a/apps/daemon/internal/agent/mcode/workspace_skills_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_skills_test.go @@ -7,8 +7,7 @@ import ( "slices" "testing" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { @@ -23,9 +22,9 @@ func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { t.Fatal(err) } req.LocalEnvironment.CapabilityRoot = root - req.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{ + req.LocalEnvironment.Skills = []proto.InstalledSkill{{ InstallationRoot: root, RelativeRoot: "plugin/skills/proof", PackageRoot: "plugin", - Metadata: agentskill.Metadata{Name: "proof", Description: "Read a marker"}, + Metadata: proto.SkillMetadata{Name: "proof", Description: "Read a marker"}, }} for range 2 { opts, err := prepareWorkspaceOptions(testStateRoot(t), c, req) diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index a1f5413f3..b3922b7dc 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -14,15 +14,13 @@ import ( // it into their native configuration without resolving credentials or packages. // Never serialize or log bindings: bearer values are confidential. type MCPBinding struct { - ServerLabel string - ConnectionOrigin string - CredentialAuthority string - Transport string - ServerURL string - AllowedTools *[]string - Required bool - BearerToken *string - HTTPHeaders map[string]string + ServerLabel string + Transport string + ServerURL string + AllowedTools *[]string + Required bool + BearerToken *string + 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. @@ -44,7 +42,7 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { 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)} + item := MCPBinding{ServerLabel: server.ServerLabel, 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) @@ -54,22 +52,16 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { names := append([]string{}, (*server.AllowedTools)...) item.AllowedTools = &names } - if server.BearerToken != nil { - item.CredentialAuthority = "project_vault" - } bindings = append(bindings, item) } } if local := req.LocalEnvironment; local != nil && len(local.MCP) > 0 { 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)} + item := MCPBinding{ServerLabel: declaration.Name, Transport: declaration.Type, ServerURL: declaration.URL, BearerToken: installed.BearerToken, HTTPHeaders: maps.Clone(declaration.HTTPHeaders)} if declaration.BearerTokenEnvVar != "" && installed.BearerToken == nil { return nil, errors.New("environment MCP credential unavailable") } - if installed.BearerToken != nil || len(declaration.HTTPHeaders) > 0 || len(declaration.EnvVars) > 0 { - item.CredentialAuthority = "environment_configuration" - } if declaration.Type == "stdio" { value := installed value.Server.Args = slices.Clone(installed.Server.Args) diff --git a/apps/daemon/internal/agent/mcp_binding_test.go b/apps/daemon/internal/agent/mcp_binding_test.go index c205380ed..f4483b443 100644 --- a/apps/daemon/internal/agent/mcp_binding_test.go +++ b/apps/daemon/internal/agent/mcp_binding_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { @@ -18,7 +17,7 @@ func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { t.Fatal("public binding unavailable", err) } b := got[0] - if b.ConnectionOrigin != "service" || b.CredentialAuthority != "project_vault" || b.Transport != "http" || !b.Required || (b.AllowedTools == nil) != (allow == nil) || *b.BearerToken != token { + if b.Transport != "http" || !b.Required || (b.AllowedTools == nil) != (allow == nil) || *b.BearerToken != token { t.Fatal("public policy or authority changed") } *b.BearerToken = "mutated" @@ -27,14 +26,14 @@ func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { } } 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}, + {Server: proto.PluginMCPServer{Name: "stdio", Type: "stdio", Command: "node", Args: []string{"tool.js"}}, PackageRoot: "plugins/proof", InstallationRoot: "/private/installed"}, + {Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp", HTTPHeaders: map[string]string{"X-Selected": "literal"}}, BearerToken: &token}, }} got, err := ResolveMCPBindings(proto.PromptRequestPayload{LocalEnvironment: local}) if err != nil || len(got) != 2 { t.Fatal("installed bindings unavailable", err) } - if got[0].ConnectionOrigin != "environment" || got[0].CredentialAuthority != "none" || got[0].Stdio.PackageRoot != "plugins/proof" || got[0].Required || got[0].AllowedTools != nil || got[1].CredentialAuthority != "environment_configuration" { + if got[0].Stdio.PackageRoot != "plugins/proof" || got[0].Required || got[0].AllowedTools != nil { t.Fatal("installed identity or authority changed") } got[0].Stdio.Server.Args[0] = "mutated" @@ -58,11 +57,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{MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}}} + local := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}}} for _, req := range []proto.PromptRequestPayload{ {MCPHTTPServers: &public, LocalEnvironment: local}, {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"}}}}}, + {LocalEnvironment: &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp", BearerTokenEnvVar: "MISSING"}}}}}, } { if _, err := ResolveMCPBindings(req); err == nil { t.Fatal("unsupported authority accepted") @@ -79,7 +78,7 @@ func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { if err != nil || len(got) != 1 { t.Fatal("environment binding unavailable", err) } - if got[0].ConnectionOrigin != "environment" || got[0].CredentialAuthority != "project_vault" || !got[0].Required || (*got[0].AllowedTools)[0] != "prove" { + if !got[0].Required || (*got[0].AllowedTools)[0] != "prove" { t.Fatal("public policy changed") } *got[0].BearerToken = "mutated" @@ -94,7 +93,7 @@ func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { } } public[0].ConnectionOrigin = "environment" - req.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}} + req.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}} if _, err := ResolveMCPBindings(req); err == nil { t.Fatal("public/Plugin identity collision accepted") } diff --git a/apps/daemon/internal/agent/steering.go b/apps/daemon/internal/agent/steering.go deleted file mode 100644 index aea8885bc..000000000 --- a/apps/daemon/internal/agent/steering.go +++ /dev/null @@ -1,14 +0,0 @@ -package agent - -import ( - "errors" -) - -// ErrSteeringNotReady means no input was sent because the turn is starting. -var ErrSteeringNotReady = errors.New("agent: turn is not ready for input") - -// ErrSteeringInactive means no input was sent because the run ended or was cancelled. -var ErrSteeringInactive = errors.New("agent: run is no longer active") - -// ErrSteeringRejected means the engine explicitly rejected the input. -var ErrSteeringRejected = errors.New("agent: input rejected") diff --git a/apps/daemon/internal/agent/unsupported.go b/apps/daemon/internal/agent/unsupported.go deleted file mode 100644 index 55cca49e6..000000000 --- a/apps/daemon/internal/agent/unsupported.go +++ /dev/null @@ -1,8 +0,0 @@ -package agent - -import "errors" - -// ErrUnsupportedOperation marks an intentional absence of optional behavior. -// Implementations wrap it with a static reason and perform no native effects. -// It is distinct from unavailable resources, failures and uncertain outcomes. -var ErrUnsupportedOperation = errors.New("agent: unsupported operation") diff --git a/apps/daemon/internal/agent/workspace_directory.go b/apps/daemon/internal/agent/workspace_directory.go deleted file mode 100644 index d344f49a9..000000000 --- a/apps/daemon/internal/agent/workspace_directory.go +++ /dev/null @@ -1,14 +0,0 @@ -package agent - -// WorkspaceDirectoryEntry describes an entry observed without following its final symlink. -type WorkspaceDirectoryEntry struct { - Name string - Kind string - SizeBytes *int64 -} - -// WorkspaceDirectoryResult is a live, bounded observation, not a filesystem snapshot. -type WorkspaceDirectoryResult struct { - Entries []WorkspaceDirectoryEntry - Truncated bool -} diff --git a/apps/daemon/internal/agent/workspace_read.go b/apps/daemon/internal/agent/workspace_read.go deleted file mode 100644 index 71f1db3e5..000000000 --- a/apps/daemon/internal/agent/workspace_read.go +++ /dev/null @@ -1,22 +0,0 @@ -package agent - -import ( - "errors" - "fmt" -) - -type WorkspaceReadResult struct { - Data []byte - Truncated bool -} - -var ( - ErrWorkspaceReadUnsupported = fmt.Errorf("%w: workspace read", ErrUnsupportedOperation) - ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") - ErrWorkspaceReadBusy = errors.New("workspace read busy") - ErrWorkspaceReadInvalid = errors.New("workspace read invalid") - ErrWorkspaceReadUncertain = errors.New("workspace read outcome uncertain") - // ErrWorkspaceNotDirectory reports that a directory request's own path is - // missing, a regular file or a symbolic link; the link was not followed. - ErrWorkspaceNotDirectory = errors.New("workspace path is not a directory") -) diff --git a/apps/daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go index ec636f67e..ab93e9df2 100644 --- a/apps/daemon/internal/auth/store.go +++ b/apps/daemon/internal/auth/store.go @@ -59,16 +59,3 @@ func Load(profile string) (Profile, error) { } return p, nil } - -// Delete removes the auth.json for a profile. Idempotent — missing -// file is not an error. -func Delete(profile string) error { - authPath, err := paths.AuthFile(profile) - if err != nil { - return err - } - if err := os.Remove(authPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("auth: delete: %w", err) - } - return nil -} diff --git a/apps/daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go index 1d3bfffcc..6a144131e 100644 --- a/apps/daemon/internal/auth/store_test.go +++ b/apps/daemon/internal/auth/store_test.go @@ -106,21 +106,3 @@ func TestLoadCorruptJSONReturnsError(t *testing.T) { t.Fatalf("Load on corrupt JSON should not be ErrNotPaired: %v", err) } } - -func TestDeleteIsIdempotent(t *testing.T) { - _ = withTempHome(t) - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err) - } - writeProfile(t, "default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}) - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete: %v", err) - } - if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNotPaired) { - t.Fatalf("Load after Delete = %v, want ErrNotPaired", err) - } - // Second Delete must still succeed. - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete second call = %v, want nil", err) - } -} diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go deleted file mode 100644 index 080ae43a3..000000000 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ /dev/null @@ -1,208 +0,0 @@ -//go:build linux - -package cli - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -type registeredSDKSender chan proto.Envelope - -func (s registeredSDKSender) Send(ctx context.Context, env proto.Envelope) error { - select { - case s <- env: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func TestLiveRegisteredClaudeSDK(t *testing.T) { - entrypoint, keyFile := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") - if entrypoint == "" || keyFile == "" { - t.Skip("requires explicit SDK runtime and real provider key file") - } - proofRoot := os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") - if !filepath.IsAbs(proofRoot) { - t.Fatal("real acceptance requires an absolute managed proof directory") - } - root, err := os.MkdirTemp(proofRoot, "claude-registered-") - if err != nil { - t.Fatal(err) - } - t.Logf("registered SDK evidence: %s", root) - t.Setenv("OAC_RUNTIME_HOME", root) - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal(err) - } - for name, value := range map[string]string{ - "ANTHROPIC_BASE_URL": "https://api.minimax.cn/anthropic", "ANTHROPIC_AUTH_TOKEN": strings.TrimSpace(string(key)), - "ANTHROPIC_API_KEY": "", "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M3", - } { - t.Setenv(name, value) - } - stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr, installedKinds: map[string]bool{"claude_sdk": true}}, "acceptance", harnessDeclarations) - if err != nil || len(discovery) != 1 || !discovery[0].runtime.Info.Available { - t.Fatal("SDK-only discovery failed", err) - } - type execution struct { - Outcome proto.DonePayload `json:"outcome"` - Events []proto.Envelope `json:"events"` - FunctionCalls int `json:"function_calls"` - AppliedResults int `json:"applied_results"` - Cancelled bool `json:"cancelled"` - } - nonce := "registered-function-" + uuid.NewString() - run := func(index int, prompt, resume string, callFunction, cancelOnText bool) execution { - t.Helper() - reg := agent.NewRegistry() - registerAgentKinds(reg, discovery) - sender := make(registeredSDKSender, 256) - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - if err != nil { - t.Fatal(err) - } - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := router.Shutdown(ctx); err != nil { - t.Error("router shutdown", err) - } - }() - ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) - defer cancel() - id, prepare := uuid.NewString(), uuid.NewString() - 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}`)}} - } - send := func(kind, envelopeID string, payload any) { - t.Helper() - env, err := proto.NewEnvelope(kind, envelopeID, payload) - if err != nil { - t.Fatal(err) - } - if err := router.Handle(ctx, env); err != nil { - t.Fatal("registered router request failed", err) - } - } - handle := func(kind string, payload any) { send(kind, id, payload) } - send(proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: "registered-acceptance", Configuration: request}) - var ready proto.PreparationStatusPayload - for ready.State != "ready" { - var event proto.Envelope - select { - case event = <-sender: - case <-ctx.Done(): - t.Fatal("registered preparation timed out", ctx.Err()) - } - if event.Type != proto.TypePreparationStatus || event.ID != prepare || event.DecodePayload(&ready) != nil || ready.State != "preparing" && ready.State != "ready" { - t.Fatalf("registered preparation failed: %s", event.Payload) - } - } - send(proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: id, Input: proto.TextInput(prompt)}) - proof := execution{} - defer func() { - data, _ := json.MarshalIndent(proof, "", " ") - if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", index)), data, 0o600); err != nil { - t.Error(err) - } - }() - completed, cancelAck := false, false - for !completed || (callFunction && proof.AppliedResults == 0) || (cancelOnText && !cancelAck) { - var event proto.Envelope - select { - case event = <-sender: - case <-ctx.Done(): - t.Fatal("registered execution timed out", ctx.Err()) - } - if event.Type == proto.TypePreparationStatus && event.ID == prepare { - var status proto.PreparationStatusPayload - if event.DecodePayload(&status) != nil || status.RunID != id || status.State != "starting" && status.State != "started" { - t.Fatalf("registered start failed: %s", event.Payload) - } - continue - } - if event.ID != id { - t.Fatal("event identity changed") - } - proof.Events = append(proof.Events, event) - switch event.Type { - case proto.TypeFunctionCall: - var call proto.FunctionCallPayload - if err := event.DecodePayload(&call); err != nil { - t.Fatal(err) - } - proof.FunctionCalls++ - if !callFunction || proof.FunctionCalls != 1 || call.Name != "lookup" { - t.Fatal("unexpected registered function call") - } - handle(proto.TypeFunctionResult, proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: "result", Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &nonce}}}) - case proto.TypeInteractionDecisionAck: - var ack proto.InteractionDecisionAckPayload - if err := event.DecodePayload(&ack); err != nil { - t.Fatal(err) - } - if !ack.Applied { - t.Fatal("registered interaction was not applied", ack) - } - if ack.DeliveryID == "result" { - proof.AppliedResults++ - } - if ack.DeliveryID == "cancel" { - cancelAck = true - } - case proto.TypeDelta: - if cancelOnText && !proof.Cancelled { - proof.Cancelled = true - handle(proto.TypePromptCancel, proto.PromptCancelPayload{DeliveryID: "cancel"}) - } - case proto.TypeError: - if !proof.Cancelled { - t.Fatalf("registered native execution failed: %s", event.Payload) - } - case proto.TypeDone: - completed = true - if err := event.DecodePayload(&proof.Outcome); err != nil { - t.Fatal(err) - } - } - } - if proof.Outcome.Content == "" || proof.Cancelled != cancelOnText { - t.Fatal("missing registered text/cancellation outcome") - } - return proof - } - first := run(1, "Call lookup exactly once with id 42 as a string. Reply with its exact returned verification value.", "", true, false) - id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || !strings.Contains(first.Outcome.Content, nonce) || first.FunctionCalls != 1 || first.AppliedResults != 1 { - t.Fatal("registered function flow failed") - } - second := run(2, "First repeat the verification value from the lookup result, then write two hundred numbered sentences about trees. Use no tools.", id, false, true) - if second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id { - t.Fatal("registered cancellation lost native identity") - } - third := run(3, "Return only the exact registered-function verification value from the earlier lookup result. Ignore the prior tree request.", id, false, false) - if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Outcome.Content, nonce) { - t.Fatal("registered cold continuation lost identity or history") - } - data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery[0].runtime.Info, "entrypoint": entrypoint, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") - if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index ab9419878..a20651dff 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -9,6 +9,8 @@ import ( "path/filepath" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" @@ -34,7 +36,7 @@ const ( // // The daemon credential comes from a Provider bootstrap file // (--bootstrap-file), self-hosted Environment enrollment -// (--remote/--environment-id/--credential-file) or the saved profile +// (--remote/--environment-id/--credential-file/--workspace) or the saved profile // written by oac-core-device. // // -b re-execs the binary in the background with stdio redirected to @@ -48,6 +50,7 @@ func runConnect(ctx *runContext, args []string) error { remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") bootstrapFile = fs.String("bootstrap-file", "", "absolute path to Provider-to-Runtime connection JSON") + workspace = fs.String("workspace", "", "existing absolute workspace directory of a self-hosted Environment") credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") ) if err := fs.Parse(args); err != nil { @@ -66,22 +69,23 @@ func runConnect(ctx *runContext, args []string) error { return fmt.Errorf("connect: %w", err) } var bootstrapped *auth.Profile + var suspendEnabled bool if *bootstrapFile != "" { - if *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { + if *remote != "" || *environment != "" || *credentialFile != "" || *workspace != "" || fs.NArg() != 0 { return errors.New("connect: bootstrap input cannot be combined with enrollment options") } - bootstrapped, err = bootstrapProfile(*bootstrapFile, ctx) + bootstrapped, suspendEnabled, err = bootstrapProfile(*bootstrapFile, ctx) if err != nil { return err } } - if *remote != "" || *environment != "" || *credentialFile != "" { + if *remote != "" || *environment != "" || *credentialFile != "" || *workspace != "" { if fs.NArg() != 0 { return errors.New("connect: Environment enrollment cannot use positional arguments") } connectCtx, stop := daemonize.NotifyContext(context.Background()) defer stop() - return runEnvironmentConnect(connectCtx, ctx, *profile, *background, false, *remote, *environment, *credentialFile) + return runEnvironmentConnect(connectCtx, ctx, *profile, *background, false, *remote, *environment, *credentialFile, *workspace, localworkspace.CapabilityDirectory) } // -b mode: parent forks, child re-enters with sentinel env set @@ -109,7 +113,7 @@ func runConnect(ctx *runContext, args []string) error { } } - return mainLoop(ctx, *profile, prof) + return mainLoop(ctx, *profile, prof, suspendEnabled) } // spawnBackground forks the daemon into the background. Parent @@ -180,16 +184,16 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string, argv [ // background process. SIGINT / SIGTERM cancels the root context, which // unblocks the read pump and any in-flight Send so the daemon exits // without orphaning agent subprocesses. -func mainLoop(rc *runContext, profile string, prof auth.Profile) error { - return mainLoopRemote(context.Background(), rc, profile, prof, "") +func mainLoop(rc *runContext, profile string, prof auth.Profile, suspend bool) error { + return mainLoopRemote(context.Background(), rc, profile, prof, "", suspend) } -func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, remote string) error { - return mainLoopRemoteWithDiscovery(parent, rc, profile, prof, remote, func(ctx context.Context) (agentCLIDiscovery, error) { +func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, remote string, suspend bool) error { + return mainLoopRemoteWithDiscovery(parent, rc, profile, prof, remote, suspend, func(ctx context.Context) (agentCLIDiscovery, error) { return preflightAgentCLIs(ctx, rc, profile) }) } -func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile string, prof auth.Profile, remote string, discover func(context.Context) (agentCLIDiscovery, error)) error { +func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile string, prof auth.Profile, remote string, suspend bool, discover func(context.Context) (agentCLIDiscovery, error)) error { // Route through obs/log so daemon log lines pick up the same // trace_id / span_id auto-injection as the server side — when the // daemon adopts an envelope's trace, every log call under that ctx @@ -204,7 +208,7 @@ func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile rootCtx, cancel := daemonize.NotifyContext(parent) defer cancel() - boot, agentCLIs, err := prepareConnection(rootCtx, discover, func(ctx context.Context) (boot *transport.BootstrapResponse, err error) { + boot, agentCLIs, err := prepareConnection(rootCtx, discover, func(ctx context.Context) (boot *machineapi.BootstrapResponse, err error) { started := time.Now() defer func() { observeRuntimeStartup(ctx, "bootstrap", started, err) }() bootCtx, stop := context.WithTimeout(ctx, bootstrapTimeout) @@ -227,7 +231,7 @@ func mainLoopRemoteWithDiscovery(parent context.Context, rc *runContext, profile registry := agent.NewRegistry() registerAgentKinds(registry, agentCLIs) - control, err := newSuspendControl() + control, err := newSuspendControl(suspend) if err != nil { return err } @@ -326,11 +330,8 @@ func advertisedKinds(registry *agent.Registry, canExport bool) []proto.Supported 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 { - return err - } +func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.Registry, boot *machineapi.BootstrapResponse, agentCLIs agentCLIDiscovery) error { + local := localworkspace.Current() router, err := dispatch.New(dispatch.Config{ Registry: registry, Sender: conn, @@ -348,9 +349,6 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { kinds := advertisedKinds(registry, local.CanExport()) return proto.HeartbeatPayload{ - Timestamp: time.Now().Unix(), - ActiveRequests: router.ActiveRuns(), - DaemonVersion: Version, SupportedAgentKinds: kinds, } }, obslog.Bg().With("component", "heartbeat")) diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go index 27705a1c8..196a4e7e7 100644 --- a/apps/daemon/internal/cli/connect_bootstrap.go +++ b/apps/daemon/internal/cli/connect_bootstrap.go @@ -3,21 +3,25 @@ package cli import ( "errors" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) // The launch file is the sole credential source for this connection. Reopening // it on process restart never reads or overwrites an auth profile. -func bootstrapProfile(path string, rc *runContext) (*auth.Profile, error) { +func bootstrapProfile(path string, rc *runContext) (*auth.Profile, bool, error) { raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes) if err != nil { - return nil, errors.New("connect: Runtime bootstrap file unavailable") + return nil, false, errors.New("connect: Runtime bootstrap file unavailable") } input, err := runtimebootstrap.Decode(raw) if err != nil { - return nil, err + return nil, false, err + } + if err = localworkspace.Bind(input.EnvironmentID, input.SessionID, input.Workspace, localworkspace.CapabilityDirectory); err != nil { + return nil, false, errors.New("connect: Runtime workspace binding unavailable") } rc.installedKinds = map[string]bool{input.Harness: true} - return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, nil + return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, input.SuspendControl, nil } diff --git a/apps/daemon/internal/cli/connect_bootstrap_test.go b/apps/daemon/internal/cli/connect_bootstrap_test.go index fbfb704ab..c8155ed2e 100644 --- a/apps/daemon/internal/cli/connect_bootstrap_test.go +++ b/apps/daemon/internal/cli/connect_bootstrap_test.go @@ -9,9 +9,11 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" + "github.com/google/uuid" ) func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { @@ -31,7 +33,8 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { if err = os.WriteFile(filepath.Join(profileDir, "auth.json"), priorRaw, 0600); err != nil { t.Fatal(err) } - input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret", Harness: "codex"} + input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, Harness: "codex", CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret", + EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Workspace: t.TempDir()} raw, err := input.Marshal() if err != nil { t.Fatal(err) @@ -40,15 +43,15 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { if err = os.WriteFile(path, raw, 0600); err != nil { t.Fatal(err) } + t.Cleanup(localworkspace.Unbind) for range 2 { - rc := &runContext{} - p, err := bootstrapProfile(path, rc) - if !rc.installedKinds["codex"] || len(rc.installedKinds) != 1 { - t.Fatal("bootstrap did not scope discovery") - } + p, _, err := bootstrapProfile(path, &runContext{}) if err != nil || p.ServerURL != input.CoreURL || p.RuntimeID != input.DeviceID || p.RunnerCredential != input.Credential { t.Fatal("failed bootstrap/restart", err) } + if bound := localworkspace.Current(); bound == nil || bound.Workspace() != input.Workspace || bound.Environment() != input.EnvironmentID { + t.Fatal("bootstrap did not bind the workspace") + } } got, err := auth.Load("default") if err != nil || got != prior { @@ -57,13 +60,13 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { if err = os.WriteFile(path, []byte("bootstrap-secret"), 0600); err != nil { t.Fatal(err) } - if _, err = bootstrapProfile(path, &runContext{}); err == nil || strings.Contains(err.Error(), input.Credential) { + if _, _, err = bootstrapProfile(path, &runContext{}); err == nil || strings.Contains(err.Error(), input.Credential) { t.Fatal("invalid input fell back or leaked") } if err = os.Remove(path); err != nil { t.Fatal(err) } - if _, err = bootstrapProfile(path, &runContext{}); err == nil { + if _, _, err = bootstrapProfile(path, &runContext{}); err == nil { t.Fatal("missing input fell back to private auth") } } @@ -74,7 +77,7 @@ func TestConnectBootstrapRejectsOtherCredentialSourcesBeforeSideEffects(t *testi for _, args := range [][]string{ {"--remote", "wss://core.example/api/v1/agent-daemon/ws"}, {"--credential-file", "/credential.json"}, - {"--environment-id", "foreign"}, {"unexpected"}, + {"--environment-id", "foreign"}, {"--workspace", "/workspace"}, {"unexpected"}, } { err := runConnect(ctx, append([]string{"--bootstrap-file", "/not-present"}, args...)) if err == nil || !strings.Contains(err.Error(), "cannot be combined") { diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index 49b7df233..b0df88163 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -1,7 +1,5 @@ package cli -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -9,15 +7,17 @@ import ( "net/http/httptest" "os" "strings" + "sync/atomic" + "testing" + "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" "github.com/gorilla/websocket" - "sync/atomic" - "testing" - "time" ) func TestReconnectWaitsForConfirmedRouterCleanup(t *testing.T) { @@ -96,7 +96,6 @@ func TestDisconnectedPumpRetainsExactExecutorUntilCleanup(t *testing.T) { } func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") peers := make(chan *websocket.Conn, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) @@ -123,7 +122,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { }) finished := make(chan error, 1) go func() { - boot := &transport.BootstrapResponse{HeartbeatSeconds: 60} + boot := &machineapi.BootstrapResponse{HeartbeatSeconds: 60} if suspend { control := &suspendControl{signal: make(chan os.Signal, 1)} finished <- runSuspendLoop(ctx, dial, registry, boot, agentCLIDiscovery{}, control) diff --git a/apps/daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go index c5f55471c..f0908bc2a 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -12,20 +12,14 @@ import ( "os" "strings" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/google/uuid" ) -type environmentEnrollment struct { - DeviceID string `json:"device_id"` - SessionID string `json:"session_id"` - EnvironmentID string `json:"environment_id"` - WorkspaceDirectory string `json:"workspace_directory"` -} - func environmentClient() *http.Client { return &http.Client{Timeout: bootstrapTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse @@ -49,11 +43,6 @@ func environmentBase(remote string) (string, error) { return u.String(), nil } -func environmentUUID(value string) bool { - id, err := uuid.Parse(value) - return err == nil && id != uuid.Nil && id.String() == value -} - // executorCredential returns the credential's key ID and secret token. func executorCredential(path, environment string) (string, string, error) { raw, err := readEnvironmentPrivateFile(path) @@ -65,7 +54,7 @@ func executorCredential(path, environment string) (string, string, error) { Token string `json:"executor_token"` EnvironmentID string `json:"environment_id,omitempty"` } - if decodeEnvironmentJSON(raw, &key) != nil || !environmentUUID(key.KeyID) || key.Token == "" || strings.ContainsAny(key.Token, " \t\r\n\x00") || (key.EnvironmentID != "" && key.EnvironmentID != environment) { + if decodeEnvironmentJSON(raw, &key) != nil || !machineapi.CanonicalUUID(key.KeyID) || key.Token == "" || strings.ContainsAny(key.Token, " \t\r\n\x00") || (key.EnvironmentID != "" && key.EnvironmentID != environment) { return "", "", errors.New("connect: invalid executor credential or Environment restriction") } return key.KeyID, key.Token, nil @@ -83,9 +72,9 @@ func decodeEnvironmentJSON(raw []byte, value any) error { return nil } -func enrollEnvironment(ctx context.Context, client *http.Client, base, environment, credential string) (environmentEnrollment, error) { - var out environmentEnrollment - body, _ := json.Marshal(map[string]string{"environment_id": environment}) +func enrollEnvironment(ctx context.Context, client *http.Client, base, environment, credential string) (machineapi.Enrollment, error) { + var out machineapi.Enrollment + body, _ := json.Marshal(machineapi.EnrollRequest{EnvironmentID: environment}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/agent-daemon/enroll", bytes.NewReader(body)) if err != nil { return out, errors.New("connect: invalid enrollment request") @@ -107,13 +96,13 @@ func enrollEnvironment(ctx context.Context, client *http.Client, base, environme return out, fmt.Errorf("connect: Environment enrollment rejected (HTTP %d)", resp.StatusCode) } raw, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024+1)) - if err != nil || len(raw) > 16*1024 || decodeEnvironmentJSON(raw, &out) != nil || !environmentUUID(out.DeviceID) || !environmentUUID(out.SessionID) || out.EnvironmentID != environment || out.WorkspaceDirectory == "/" || agentcapabilities.ValidateLocalDirectories([]string{out.WorkspaceDirectory}) != nil { - return environmentEnrollment{}, errors.New("connect: invalid Environment enrollment response") + if err != nil || len(raw) > 16*1024 || decodeEnvironmentJSON(raw, &out) != nil || out.Validate(environment) != nil || out.WorkspaceDirectory == "/" || agentcapabilities.ValidateLocalDirectories([]string{out.WorkspaceDirectory}) != nil { + return machineapi.Enrollment{}, errors.New("connect: invalid Environment enrollment response") } return out, nil } -func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) (*transport.BootstrapResponse, error) { +func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) (*machineapi.BootstrapResponse, error) { boot, err := transport.BootstrapWithClient(ctx, environmentClient(), prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) if err != nil { return nil, errors.New("connect: Environment bootstrap failed") @@ -127,13 +116,13 @@ func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) // exitOnRejection selects the native `start` behaviour: a permanent rejection // ends the process with the message as its error. Otherwise the process that // owns the connection parks (see parkEnvironment). -func runEnvironmentConnect(parent context.Context, rc *runContext, profile string, background, exitOnRejection bool, remote, environment, credentialFile string) error { +func runEnvironmentConnect(parent context.Context, rc *runContext, profile string, background, exitOnRejection bool, remote, environment, credentialFile, workspace, capabilityDirectory string) error { initializeRuntimeObservations(rc) base, err := environmentBase(remote) if err != nil { return err } - if !environmentUUID(environment) { + if !machineapi.CanonicalUUID(environment) { return errors.New("connect: canonical Environment ID required") } if err = checkEnvironmentTarget(remote, environment); err != nil { @@ -166,14 +155,14 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin if err = parent.Err(); err != nil { return err } - if err = bindEnvironmentRuntime(remote, bound, credentialFile); err != nil { + if err = bindEnvironmentRuntime(remote, bound, workspace, capabilityDirectory); err != nil { return err } if background && !daemonize.IsBackgroundChild() { return spawnBackground(parent, rc, profile, os.Args) } prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential} - return rejected(mainLoopRemote(parent, rc, profile, prof, remote)) + return rejected(mainLoopRemote(parent, rc, profile, prof, remote, false)) } var ( diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index 9cf2f0ca0..a9371407f 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -6,16 +6,19 @@ import ( "os" "path/filepath" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) // This receipt contains identity only; executor credentials remain in their file. type environmentBinding struct { RemoteURL string `json:"remote_url"` - Enrollment environmentEnrollment `json:"enrollment"` + Enrollment machineapi.Enrollment `json:"enrollment"` LocalWorkspace string `json:"local_workspace"` CapabilityDirectory string `json:"capability_directory"` } @@ -41,7 +44,7 @@ func readEnvironmentPrivateFile(path string) ([]byte, error) { return runtimefs.ReadPrivatePath(path, 16*1024) } -func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { +func bindEnvironmentRuntime(remote string, bound machineapi.Enrollment, workspace, capabilityDirectory string) error { root, err := paths.Root() if err != nil || !filepath.IsAbs(root) { return errors.New("connect: absolute Runtime state directory required") @@ -49,7 +52,6 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if err = runtimefs.EnsurePrivateDir(root); err != nil { return errors.New("connect: Runtime state directory unavailable") } - workspace := os.Getenv("OAC_RUNTIME_WORKSPACE") if workspace == "/" || agentcapabilities.ValidateLocalDirectories([]string{workspace}) != nil { return errors.New("connect: clean absolute Runtime workspace required") } @@ -57,36 +59,13 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if statErr != nil || !info.IsDir() { return errors.New("connect: existing Runtime workspace required") } - if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { + if bound.WorkspaceDirectory != runtimebootstrap.PublicWorkspace && bound.WorkspaceDirectory != workspace { return errors.New("connect: enrollment does not match the Runtime workspace") } - 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") - } - } - capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if capabilityDirectory == "" { - capabilityDirectory = localworkspace.CapabilityDirectory - } - if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { + if err := localworkspace.Bind(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { return errors.New("connect: local Runtime layout unavailable") } - want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory} - if err = saveEnvironmentBinding(root, want); err != nil { - return err - } - for key, value := range identity { - if err = os.Setenv(key, value); err != nil { - return errors.New("connect: Runtime identity configuration failed") - } - } - local, err := localworkspace.Load() - if err != nil || local == nil { - return errors.New("connect: Runtime binding unavailable") - } - return nil + return saveEnvironmentBinding(root, environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory}) } func saveEnvironmentBinding(root string, want environmentBinding) error { diff --git a/apps/daemon/internal/cli/connect_environment_binding_test.go b/apps/daemon/internal/cli/connect_environment_binding_test.go index bf9e91728..df7b7b4cd 100644 --- a/apps/daemon/internal/cli/connect_environment_binding_test.go +++ b/apps/daemon/internal/cli/connect_environment_binding_test.go @@ -5,10 +5,13 @@ import ( "path/filepath" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/google/uuid" ) -func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string, bound environmentEnrollment) { +func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string, bound machineapi.Enrollment) { t.Helper() base := t.TempDir() root, workspace = filepath.Join(base, "private"), filepath.Join(base, "workspace") @@ -22,11 +25,8 @@ func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv("OAC_RUNTIME_WORKSPACE", workspace) - 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"} + t.Cleanup(localworkspace.Unbind) + return root, workspace, credential, machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), WorkspaceDirectory: "/workspace"} } func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { @@ -36,7 +36,7 @@ func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { if physical { bound.WorkspaceDirectory = workspace } - if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err != nil { + if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, workspace, localworkspace.CapabilityDirectory); err != nil { t.Fatal(err) } raw, err := readEnvironmentPrivateFile(filepath.Join(root, "daemon", "environment.json")) @@ -47,15 +47,14 @@ func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { if decodeEnvironmentJSON(raw, &receipt) != nil || receipt.LocalWorkspace != workspace || receipt.Enrollment != bound { t.Fatal("workspace identity changed", receipt) } - if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err != nil { + if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, workspace, localworkspace.CapabilityDirectory); err != nil { t.Fatal("same binding refused", err) } replacement := filepath.Join(filepath.Dir(workspace), "replacement") if err := os.Mkdir(replacement, 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_RUNTIME_WORKSPACE", replacement) - if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err == nil { + if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, replacement, localworkspace.CapabilityDirectory); err == nil { t.Fatal("existing workspace identity replaced") } after, _ := os.ReadFile(filepath.Join(root, "daemon", "environment.json")) @@ -71,9 +70,9 @@ func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { } func TestBindEnvironmentRuntimeChecksIdentityAndPathValidity(t *testing.T) { - for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment", "credential outside private", "identity conflict"} { + for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment"} { t.Run(kind, func(t *testing.T) { - root, workspace, credential, bound := environmentRuntimeFixture(t) + root, workspace, _, bound := environmentRuntimeFixture(t) selected := workspace switch kind { case "relative": @@ -111,17 +110,9 @@ func TestBindEnvironmentRuntimeChecksIdentityAndPathValidity(t *testing.T) { } case "foreign enrollment": bound.WorkspaceDirectory = filepath.Join(filepath.Dir(workspace), "different") - case "credential outside private": - credential = filepath.Join(filepath.Dir(root), "credential") - if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { - t.Fatal(err) - } - case "identity conflict": - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", uuid.NewString()) } - t.Setenv("OAC_RUNTIME_WORKSPACE", selected) - allowed := kind == "symlink" || kind == "parent symlink" || kind == "workspace contains private" || kind == "workspace inside private" || kind == "credential outside private" - err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential) + allowed := kind == "symlink" || kind == "parent symlink" || kind == "workspace contains private" || kind == "workspace inside private" + err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, selected, localworkspace.CapabilityDirectory) if allowed { if err != nil { t.Fatal("operator layout rejected", err) diff --git a/apps/daemon/internal/cli/connect_environment_test.go b/apps/daemon/internal/cli/connect_environment_test.go index 8d398b59b..fe704a709 100644 --- a/apps/daemon/internal/cli/connect_environment_test.go +++ b/apps/daemon/internal/cli/connect_environment_test.go @@ -11,6 +11,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/google/uuid" ) @@ -31,7 +33,7 @@ func TestEnvironmentConnectionURL(t *testing.T) { func TestEnvironmentEnrollmentAndBootstrap(t *testing.T) { environment := uuid.NewString() - want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} + want := machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: environment, WorkspaceDirectory: "/workspace"} var remote string var enrolls, bootstraps int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -154,7 +156,7 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), WorkspaceDirectory: "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } @@ -191,7 +193,7 @@ func TestEnvironmentBindingAllowsOperatorFilePermissions(t *testing.T) { if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), WorkspaceDirectory: "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := os.WriteFile(filepath.Join(root, "installed-bundle"), []byte("bundle"), 0600); err != nil { t.Fatal(err) } @@ -213,7 +215,7 @@ func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", root) - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), WorkspaceDirectory: "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } @@ -230,8 +232,8 @@ func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { environment := uuid.NewString() - good := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} - for _, mutate := range []func(*environmentEnrollment){func(b *environmentEnrollment) { b.EnvironmentID = uuid.NewString() }, func(b *environmentEnrollment) { b.DeviceID = "" }, func(b *environmentEnrollment) { b.SessionID = "invalid" }, func(b *environmentEnrollment) { b.WorkspaceDirectory = "relative" }} { + good := machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: environment, WorkspaceDirectory: "/workspace"} + for _, mutate := range []func(*machineapi.Enrollment){func(b *machineapi.Enrollment) { b.EnvironmentID = uuid.NewString() }, func(b *machineapi.Enrollment) { b.DeviceID = "" }, func(b *machineapi.Enrollment) { b.SessionID = "invalid" }, func(b *machineapi.Enrollment) { b.WorkspaceDirectory = "relative" }} { bad := good mutate(&bad) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(bad) })) @@ -245,7 +247,7 @@ func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { func TestEnvironmentEnrollmentAcceptsPhysicalWorkspaceSelection(t *testing.T) { environment := uuid.NewString() - want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/srv/runtime/workspace"} + want := machineapi.Enrollment{DeviceID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: environment, WorkspaceDirectory: "/srv/runtime/workspace"} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(want) })) defer server.Close() actual, err := enrollEnvironment(t.Context(), environmentClient(), server.URL, environment, "secret") diff --git a/apps/daemon/internal/cli/connect_startup.go b/apps/daemon/internal/cli/connect_startup.go index 68258f297..1b7294b3f 100644 --- a/apps/daemon/internal/cli/connect_startup.go +++ b/apps/daemon/internal/cli/connect_startup.go @@ -5,20 +5,20 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" ) // prepareConnection overlaps independent preflight work after local credentials // and enrollment are resolved. Both workers are joined before returning, including // on failure. Their temporary context never owns the live connection or executor. -func prepareConnection(parent context.Context, discover func(context.Context) (agentCLIDiscovery, error), bootstrap func(context.Context) (*transport.BootstrapResponse, error)) (*transport.BootstrapResponse, agentCLIDiscovery, error) { +func prepareConnection(parent context.Context, discover func(context.Context) (agentCLIDiscovery, error), bootstrap func(context.Context) (*machineapi.BootstrapResponse, error)) (*machineapi.BootstrapResponse, agentCLIDiscovery, error) { if err := parent.Err(); err != nil { return nil, nil, err } ctx, cancel := context.WithCancel(parent) defer cancel() var agents agentCLIDiscovery - var boot *transport.BootstrapResponse + var boot *machineapi.BootstrapResponse done := make(chan error, 2) go func() { var err error diff --git a/apps/daemon/internal/cli/connect_startup_test.go b/apps/daemon/internal/cli/connect_startup_test.go index bf9ee9530..ad9da371a 100644 --- a/apps/daemon/internal/cli/connect_startup_test.go +++ b/apps/daemon/internal/cli/connect_startup_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" ) func awaitStartup(t *testing.T, ch <-chan struct{}) { @@ -32,13 +32,13 @@ func TestConnectionPreflightOverlapsAndJoins(t *testing.T) { discoveryStarted, bootstrapStarted := make(chan struct{}), make(chan struct{}) releaseDiscovery, releaseBootstrap := make(chan struct{}), make(chan struct{}) done := make(chan error, 1) - boot := &transport.BootstrapResponse{DeviceID: "device"} + boot := &machineapi.BootstrapResponse{DeviceID: "device"} go func() { result, _, err := prepareConnection(t.Context(), func(context.Context) (agentCLIDiscovery, error) { close(discoveryStarted) <-releaseDiscovery return agentCLIDiscovery{}, nil - }, func(context.Context) (*transport.BootstrapResponse, error) { + }, func(context.Context) (*machineapi.BootstrapResponse, error) { close(bootstrapStarted) <-releaseBootstrap return boot, nil @@ -97,7 +97,7 @@ func TestConnectionPreflightFailureCancelsAndJoinsSibling(t *testing.T) { return nil, fail() } return nil, wait(ctx) - }, func(ctx context.Context) (*transport.BootstrapResponse, error) { + }, func(ctx context.Context) (*machineapi.BootstrapResponse, error) { if failing == "bootstrap" { return nil, fail() } @@ -136,7 +136,7 @@ func TestConnectionPreflightParentCancellation(t *testing.T) { started <- struct{}{} <-ctx.Done() return nil, ctx.Err() - }, func(ctx context.Context) (*transport.BootstrapResponse, error) { + }, func(ctx context.Context) (*machineapi.BootstrapResponse, error) { started <- struct{}{} <-ctx.Done() return nil, ctx.Err() @@ -157,7 +157,7 @@ func TestConnectionPreflightParentCancellation(t *testing.T) { _, _, err := prepareConnection(ctx, func(context.Context) (agentCLIDiscovery, error) { t.Error("discovery started after cancellation") return nil, nil - }, func(context.Context) (*transport.BootstrapResponse, error) { + }, func(context.Context) (*machineapi.BootstrapResponse, error) { t.Error("bootstrap started after cancellation") return nil, nil }) @@ -179,9 +179,9 @@ func BenchmarkConnectionPreflight(b *testing.B) { time.Sleep(5 * time.Millisecond) return agentCLIDiscovery{}, nil } - bootstrap := func(context.Context) (*transport.BootstrapResponse, error) { + bootstrap := func(context.Context) (*machineapi.BootstrapResponse, error) { time.Sleep(5 * time.Millisecond) - return &transport.BootstrapResponse{}, nil + return &machineapi.BootstrapResponse{}, nil } for b.Loop() { if parallel { @@ -239,7 +239,7 @@ func TestMainLoopOverlapsPreflightWithoutEarlyRegistration(t *testing.T) { http.Error(w, "rejected", http.StatusUnauthorized) return } - _ = json.NewEncoder(w).Encode(transport.BootstrapResponse{DeviceID: "device"}) + _ = json.NewEncoder(w).Encode(machineapi.BootstrapResponse{DeviceID: "device"}) case "/agent-daemon/ws": select { case <-completed: @@ -256,7 +256,7 @@ func TestMainLoopOverlapsPreflightWithoutEarlyRegistration(t *testing.T) { defer server.Close() ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() - err := mainLoopRemote(ctx, &runContext{stdout: io.Discard, stderr: io.Discard}, "default", auth.Profile{ServerURL: server.URL, RuntimeID: "device", RunnerCredential: "fixture"}, "") + err := mainLoopRemote(ctx, &runContext{stdout: io.Discard, stderr: io.Discard}, "default", auth.Profile{ServerURL: server.URL, RuntimeID: "device", RunnerCredential: "fixture"}, "", false) if err == nil || ctx.Err() != nil { t.Fatalf("unexpected completion: %v, context %v", err, ctx.Err()) } diff --git a/apps/daemon/internal/cli/connect_suspend.go b/apps/daemon/internal/cli/connect_suspend.go index 5fb40fb31..1f5416950 100644 --- a/apps/daemon/internal/cli/connect_suspend.go +++ b/apps/daemon/internal/cli/connect_suspend.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" @@ -39,10 +41,7 @@ type suspendedRouter struct { } func newSuspendedRouter(conn *transport.Conn, registry *agent.Registry) (*suspendedRouter, error) { - local, err := localworkspace.Load() - if err != nil { - return nil, err - } + local := localworkspace.Current() sender := &reconnectSender{conn: conn} router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, Log: obslog.Bg(), LocalWorkspace: local}) if err != nil { @@ -57,7 +56,7 @@ func (s *suspendedRouter) shutdown() { // runSuspendLoop uses the ordinary connection authentication and dispatch chain. // Only an acknowledged, fully drained suspension retains a Router across sockets. -func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent.Registry, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { +func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent.Registry, boot *machineapi.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { for ctx.Err() == nil { conn, err := transport.Reconnect(ctx, dial, transport.DefaultBackoff, nil) if err != nil { @@ -89,7 +88,7 @@ func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent. return nil } -func serveSuspendLifecycle(ctx context.Context, conn *transport.Conn, dial transport.DialFn, state *suspendedRouter, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { +func serveSuspendLifecycle(ctx context.Context, conn *transport.Conn, dial transport.DialFn, state *suspendedRouter, boot *machineapi.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { defer func() { _ = conn.Close() }() for { request, err := state.pump(ctx, conn, boot, discovery, control) @@ -150,14 +149,14 @@ func (s *suspendedRouter) reconnectSuspension(ctx context.Context, dial transpor } } -func (s *suspendedRouter) heartbeats(ctx context.Context, conn *transport.Conn, boot *transport.BootstrapResponse, discovery agentCLIDiscovery) { +func (s *suspendedRouter) heartbeats(ctx context.Context, conn *transport.Conn, boot *machineapi.BootstrapResponse, discovery agentCLIDiscovery) { conn.StartHeartbeats(ctx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { kinds := advertisedKinds(s.registry, s.local.CanExport()) - return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, SupportedAgentKinds: kinds} + return proto.HeartbeatPayload{SupportedAgentKinds: kinds} }, obslog.Bg()) } -func (s *suspendedRouter) pump(ctx context.Context, conn *transport.Conn, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) (*proto.EnvironmentSuspendPayload, error) { +func (s *suspendedRouter) pump(ctx context.Context, conn *transport.Conn, boot *machineapi.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) (*proto.EnvironmentSuspendPayload, error) { s.heartbeats(ctx, conn, boot, discovery) for { select { diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go index 9b290ad80..0222ad112 100644 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -15,9 +15,11 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" @@ -28,7 +30,6 @@ import ( func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) { for _, scenario := range []string{"resume", "rollback", "reconnect", "revoked", "wrong_operation"} { t.Run(scenario, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") var connections atomic.Int32 peers := make(chan *websocket.Conn, 3) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -56,7 +57,7 @@ func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - done <- runSuspendLoop(ctx, dial, registry, &transport.BootstrapResponse{HeartbeatSeconds: 1}, agentCLIDiscovery{}, control) + done <- runSuspendLoop(ctx, dial, registry, &machineapi.BootstrapResponse{HeartbeatSeconds: 1}, agentCLIDiscovery{}, control) }() var first *websocket.Conn select { @@ -187,7 +188,6 @@ func readLifecycleResult(t *testing.T, peer *websocket.Conn, kind string) proto. } func TestRunningSourceOnlyAcceptsExplicitRollbackOrItsReceipt(t *testing.T) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") peers := make(chan *websocket.Conn, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) @@ -204,7 +204,7 @@ func TestRunningSourceOnlyAcceptsExplicitRollbackOrItsReceipt(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - done <- runSuspendLoop(ctx, dial, agent.NewRegistry(), &transport.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) + done <- runSuspendLoop(ctx, dial, agent.NewRegistry(), &machineapi.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) }() var peer *websocket.Conn select { @@ -240,7 +240,6 @@ func TestRunningSourceOnlyAcceptsExplicitRollbackOrItsReceipt(t *testing.T) { func TestSuspensionReconnectBeforeConfirmation(t *testing.T) { for _, scenario := range []string{"disconnect", "timeout", "revoked", "cancelled", "deleted"} { t.Run(scenario, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") var attempts atomic.Int32 peers := make(chan *websocket.Conn, 2) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -343,10 +342,10 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test environment, session := uuid.NewString(), uuid.NewString() t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) t.Setenv("OAC_RUNTIME_STATE_DIRECTORY", t.TempDir()) - t.Setenv("OAC_RUNTIME_WORKSPACE", t.TempDir()) - t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", t.TempDir()) - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", environment) - t.Setenv("OAC_RUNTIME_SESSION_ID", session) + if err := localworkspace.Bind(environment, session, t.TempDir(), t.TempDir()); err != nil { + t.Fatal(err) + } + t.Cleanup(localworkspace.Unbind) 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) @@ -374,7 +373,7 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test control := &suspendControl{path: filepath.Join(t.TempDir(), "control.json"), identity: suspendIdentity{EnvironmentID: environment}, signal: make(chan os.Signal, 1)} finished := make(chan error, 1) go func() { - finished <- runSuspendLoop(ctx, dial, registry, &transport.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) + finished <- runSuspendLoop(ctx, dial, registry, &machineapi.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) }() var peer *websocket.Conn select { @@ -383,7 +382,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: proto.SessionStateKey(session), LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}}) + preparation, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: proto.SessionStateKey(session), LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &proto.CapabilitySources{}}}}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/logout.go b/apps/daemon/internal/cli/logout.go deleted file mode 100644 index 553c6fa57..000000000 --- a/apps/daemon/internal/cli/logout.go +++ /dev/null @@ -1,46 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -// runLogout removes the credential file for a profile. Idempotent -// so CI scripts can re-run it on every step. -// -// Logout is local-only: revoking the runtime on the server is the -// admin UI's disable-runtime button (which also kicks any live WS). -func runLogout(ctx *runContext, args []string) error { - fs := newFlagSet("logout") - profile := fs.String("profile", paths.DefaultProfile, "profile name to forget") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("logout: parse flags: %w", err) - } - if err := paths.ValidateProfile(*profile); err != nil { - return fmt.Errorf("logout: %w", err) - } - - // Stat first so we emit a distinct message for "nothing to - // remove" vs. "removed". - authPath, err := paths.AuthFile(*profile) - if err != nil { - return fmt.Errorf("logout: %w", err) - } - _, statErr := os.Stat(authPath) - missing := errors.Is(statErr, os.ErrNotExist) - - if err := auth.Delete(*profile); err != nil { - return fmt.Errorf("logout: %w", err) - } - if missing { - fmt.Fprintf(ctx.stdout, "Profile %q already had no credential — nothing to forget.\n", *profile) - } else { - fmt.Fprintf(ctx.stdout, "Forgot credential for profile %q (removed %s).\n", *profile, authPath) - } - fmt.Fprintln(ctx.stdout, "Note: this only removes local state. To revoke the runtime on the server, disable it in the admin UI.") - return nil -} diff --git a/apps/daemon/internal/cli/native_download_test.go b/apps/daemon/internal/cli/native_download_test.go index 86b9f7c91..d13232e29 100644 --- a/apps/daemon/internal/cli/native_download_test.go +++ b/apps/daemon/internal/cli/native_download_test.go @@ -21,7 +21,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -52,7 +53,7 @@ func newFakeCore(t *testing.T, workspace string) (*fakeCore, string) { w.WriteHeader(core.status) return } - _ = json.NewEncoder(w).Encode(v1.NativeInstallationContext{Version: Version, ProtocolVersion: proto.Version, EnvironmentID: environment, + _ = json.NewEncoder(w).Encode(machineapi.InstallationContext{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+"-"), ".") @@ -105,7 +106,7 @@ func (c *fakeCore) add(t *testing.T, name string) nativeComponent { func nativeDownloadInstall(t *testing.T, core *fakeCore, bundle string, extra ...string) error { t.Helper() - t.Setenv(v1.NativeInstallGrantEnv, "grant") + t.Setenv(machineapi.InstallGrantEnv, "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...)) diff --git a/apps/daemon/internal/cli/native_harness.go b/apps/daemon/internal/cli/native_harness.go index 34bc1f300..7b13ccda2 100644 --- a/apps/daemon/internal/cli/native_harness.go +++ b/apps/daemon/internal/cli/native_harness.go @@ -15,6 +15,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + claudedeclaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" + codexdeclaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" + mcodedeclaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) func nativeExe(name string) string { @@ -69,7 +72,9 @@ func withNativeEnv(values map[string]string) []string { // Installation registration is local to Runtime; Core does not see native paths. var nativeHarnesses = map[string]agent.Installation{ - "codex": codex.Installation(), "claude": claudesdk.Installation(), "minimax": mcode.Installation(), + codexdeclaration.Declaration.Native.Component: codex.Installation(), + claudedeclaration.Declaration.Native.Component: claudesdk.Installation(), + mcodedeclaration.Declaration.Native.Component: mcode.Installation(), } var probeNativeInstallation = checkNativeInstallation diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go index dbec5a2f2..609c463b4 100644 --- a/apps/daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -13,6 +13,8 @@ import ( "slices" "strings" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" @@ -71,7 +73,7 @@ func validateNativeInstallation(c nativeInstallation) error { if _, err := environmentBase(c.Remote); err != nil { return err } - if !environmentUUID(c.Environment) { + if !machineapi.CanonicalUUID(c.Environment) { return errors.New("install: --environment-id requires a canonical Environment ID") } for _, p := range []string{c.Workspace, c.Credential, c.CapabilityDirectory} { @@ -347,7 +349,6 @@ func runStart(rc *runContext, args []string) error { ctx, stop := daemonize.NotifyContext(context.Background()) defer stop() flags := newFlagSet("start") - foreground := flags.Bool("foreground", false, "stay attached to this terminal") if err := flags.Parse(args); err != nil { return errors.New("start: invalid arguments") } @@ -387,15 +388,13 @@ func runStart(rc *runContext, args []string) error { rc.installedKinds = nativeInstallationKinds(config.Harnesses) defer func() { rc.installedKinds = previousKinds }() 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 for key, value := range values { if err = os.Setenv(key, value); err != nil { return err } } - return runEnvironmentConnect(ctx, rc, paths.DefaultProfile, !*foreground, true, config.Remote, config.Environment, config.Credential) + return runEnvironmentConnect(ctx, rc, paths.DefaultProfile, true, true, config.Remote, config.Environment, config.Credential, config.Workspace, config.CapabilityDirectory) } func useInstalledNativeHome() { diff --git a/apps/daemon/internal/cli/native_install_input.go b/apps/daemon/internal/cli/native_install_input.go index 1ced32af7..7d06d2fb6 100644 --- a/apps/daemon/internal/cli/native_install_input.go +++ b/apps/daemon/internal/cli/native_install_input.go @@ -10,8 +10,9 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) type nativeInstallOptions struct { @@ -24,8 +25,8 @@ type nativeInstallOptions struct { func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { var o nativeInstallOptions // Take the grant first and drop it from the environment, which start, the daemon and Harnesses inherit. - o.Authorization = os.Getenv(v1.NativeInstallGrantEnv) - _ = os.Unsetenv(v1.NativeInstallGrantEnv) + o.Authorization = os.Getenv(machineapi.InstallGrantEnv) + _ = os.Unsetenv(machineapi.InstallGrantEnv) flags := newFlagSet("install") flags.StringVar(&o.OnboardURL, "onboard-url", "", "Core installation endpoint") flags.StringVar(&o.Remote, "remote", "", "Environment remote_url from Core") @@ -37,7 +38,6 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default /environments/; Runtime home is OAC_RUNTIME_HOME or ~/.oac)") flags.StringVar(&o.Bundle, "bundle-dir", "", "native distribution directory (defaults beside the executable)") flags.StringVar(&o.Harness, "harness", "", "comma-separated codex,claude,minimax") - flags.BoolVar(&o.Interactive, "interactive", false, "ask for missing installation options") flags.BoolVar(&o.NonInteractive, "non-interactive", false, "require command-line options; never prompt") if err := flags.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -48,8 +48,8 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er // flag errors can contain supplied values, including accidental secrets. return o, errors.New("install: invalid arguments; run install --help (use --credential-file for credentials)") } - if flags.NArg() != 0 || (o.Interactive && o.NonInteractive) { - return o, errors.New("install: unexpected arguments or conflicting interaction modes") + if flags.NArg() != 0 { + return o, errors.New("install: unexpected arguments") } if err := prepareOnboarding(&o); err != nil { return o, err diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index bae3f95b7..e5999f038 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -14,7 +14,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/google/uuid" ) @@ -144,7 +145,7 @@ func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { if err := runInstall(rc, args); err == nil { t.Fatal("accepted historical installation") } - if err := runStart(rc, []string{"--foreground"}); err == nil { + if err := runStart(rc, nil); err == nil { t.Fatal("started historical installation") } if got, _ := os.ReadFile(file); !bytes.Equal(got, raw) { @@ -188,7 +189,7 @@ func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { func TestNativeInstallationMissingInputAndSecrets(t *testing.T) { rc, _, root, _ := nativeInstallFixture(t) - for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--interactive=secret"}, {"--token", "private-test-credential"}} { + for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--token", "private-test-credential"}} { err := runInstall(rc, args) if err == nil { t.Fatal("accepted incomplete input") @@ -329,14 +330,14 @@ func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { func TestNativeInstallTakesGrantOutOfTheEnvironment(t *testing.T) { rc, _, _, _ := nativeInstallFixture(t) - t.Setenv(v1.NativeInstallGrantEnv, "private-grant") + t.Setenv(machineapi.InstallGrantEnv, "private-grant") // The endpoint is invalid so parsing stops before it contacts Core; the grant is already taken. o, _ := parseNativeInstall(rc, []string{"--onboard-url", "https://core.example/other", "--non-interactive"}) if o.Authorization != "private-grant" { t.Fatal("grant not read from the environment") } for _, entry := range withNativeEnv(nil) { - if strings.HasPrefix(entry, v1.NativeInstallGrantEnv+"=") { + if strings.HasPrefix(entry, machineapi.InstallGrantEnv+"=") { t.Fatal("grant reaches child processes") } } diff --git a/apps/daemon/internal/cli/native_onboarding.go b/apps/daemon/internal/cli/native_onboarding.go index f5e517416..992579149 100644 --- a/apps/daemon/internal/cli/native_onboarding.go +++ b/apps/daemon/internal/cli/native_onboarding.go @@ -16,9 +16,10 @@ import ( "strings" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) @@ -65,7 +66,7 @@ func prepareOnboarding(o *nativeInstallOptions) error { return nil } if o.OnboardURL == "" || o.Authorization == "" { - return errors.New("install: --onboard-url and " + v1.NativeInstallGrantEnv + " are required together") + return errors.New("install: --onboard-url and " + machineapi.InstallGrantEnv + " are required together") } const suffix = "/api/v1/agent-daemon/installation" if !strings.HasSuffix(o.OnboardURL, suffix) { @@ -76,14 +77,14 @@ func prepareOnboarding(o *nativeInstallOptions) error { if _, err := environmentBase(remote); err != nil { return err } - var info v1.NativeInstallationContext + var info machineapi.InstallationContext if err := installationRequest(context.Background(), o.OnboardURL, o.Authorization, nil, &info); err != nil { return err } if info.Version != Version || !proto.VersionCompatible(info.ProtocolVersion) { return errors.New("install: installer does not match Core; obtain a fresh command from the target Core") } - if info.RemoteURL != remote || !environmentUUID(info.EnvironmentID) { + if info.RemoteURL != remote || !machineapi.CanonicalUUID(info.EnvironmentID) { return errors.New("install: Core returned an invalid connection binding") } if (o.Remote != "" && o.Remote != info.RemoteURL) || (o.Environment != "" && o.Environment != info.EnvironmentID) || (o.Workspace != "" && o.Workspace != info.Workspace) || o.Credential != "" { @@ -145,7 +146,7 @@ func prepareOnboardingCredential(ctx context.Context, o *nativeInstallOptions, h } else { return err } - err := installationRequest(ctx, o.OnboardURL+"/claim", o.Authorization, map[string]string{"executor_token": secret}, nil) + err := installationRequest(ctx, o.OnboardURL+"/claim", o.Authorization, machineapi.InstallationClaim{ExecutorToken: secret}, nil) if errors.Is(err, errCredentialClaimed) { // This directory holds no installation, and Core never accepts this secret. _ = held.Remove("executor-credential.json") @@ -214,12 +215,9 @@ func installedEnvironmentConnected(ctx context.Context, base, environment, secre if response.StatusCode != http.StatusOK { return false, nil } - var result struct { - Environment string `json:"environment_id"` - Status string `json:"status"` - } - if json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&result) != nil || result.Environment != environment { + var result machineapi.Connection + if json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&result) != nil || result.EnvironmentID != environment { return false, errors.New("Daemon connection: invalid status returned by Core") } - return result.Status == "connected", nil + return result.Status == machineapi.ConnectionConnected, nil } 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 5f639f826..24f2812b9 100644 --- a/apps/daemon/internal/cli/native_start_interrupt_unix_test.go +++ b/apps/daemon/internal/cli/native_start_interrupt_unix_test.go @@ -15,6 +15,8 @@ import ( "strings" "testing" "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" ) func TestNativeStartInterruptHelper(t *testing.T) { @@ -39,7 +41,7 @@ func TestNativeStartInterruptCancelsEnrollment(t *testing.T) { if err := readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &config); err != nil { return } - _ = json.NewEncoder(w).Encode(environmentEnrollment{ + _ = json.NewEncoder(w).Encode(machineapi.Enrollment{ DeviceID: "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", SessionID: "bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb", EnvironmentID: config.Environment, diff --git a/apps/daemon/internal/cli/native_uninstall.go b/apps/daemon/internal/cli/native_uninstall.go index 4e875d730..2ac1bf340 100644 --- a/apps/daemon/internal/cli/native_uninstall.go +++ b/apps/daemon/internal/cli/native_uninstall.go @@ -107,8 +107,9 @@ func runUninstall(rc *runContext, args []string) error { // Only empty directories go; anything left belongs to the user. _ = os.Remove(filepath.Join(root, "daemon")) if kept { - // Windows keeps the running executable open until this process exits. - deleteAfterExit(root) + // Windows keeps the running executable open until this process exits; + // the helper starts last so its short wait begins at exit. + defer deleteAfterExit(root) } else { _ = os.Remove(root) } diff --git a/apps/daemon/internal/cli/native_uninstall_test.go b/apps/daemon/internal/cli/native_uninstall_test.go index 604d37e72..d9dcbf0d2 100644 --- a/apps/daemon/internal/cli/native_uninstall_test.go +++ b/apps/daemon/internal/cli/native_uninstall_test.go @@ -29,7 +29,7 @@ func TestNativeStartExitsOnPermanentRejection(t *testing.T) { t.Fatal(err) } rc := &runContext{stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)} - err := runEnvironmentConnect(t.Context(), rc, "default", false, true, "ws"+strings.TrimPrefix(server.URL, "http")+"/api/v1/agent-daemon/ws", environment, credential) + err := runEnvironmentConnect(t.Context(), rc, "default", false, true, "ws"+strings.TrimPrefix(server.URL, "http")+"/api/v1/agent-daemon/ws", environment, credential, t.TempDir(), t.TempDir()) if err == nil || !strings.Contains(err.Error(), "run oac-daemon uninstall") || !strings.Contains(err.Error(), keyID) || strings.Contains(err.Error(), "private-canary") { t.Fatalf("rejection: %v", err) } diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index a2bcdad44..dd9114d14 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -33,7 +33,7 @@ func defaultRunContext() *runContext { } // commands lists subcommands in --help render order: the user's -// likely flow connect → status → stop / logs → logout. +// likely flow connect → status → stop / logs. var commands = []command{ {name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall}, {name: "start", summary: "Start the installed native daemon", run: runStart}, @@ -44,7 +44,6 @@ var commands = []command{ {name: "status", summary: "Print the credential profile and daemon state", run: runStatus}, {name: "stop", summary: "Stop a background `connect -b` daemon", run: runStop}, {name: "logs", summary: "Tail the background daemon's log file", run: runLogs}, - {name: "logout", summary: "Forget the credential for a profile", run: runLogout}, {name: "version", summary: "Print the daemon version and exit", run: runVersion}, } diff --git a/apps/daemon/internal/cli/root_test.go b/apps/daemon/internal/cli/root_test.go index 68a8cca4e..71fae9ef9 100644 --- a/apps/daemon/internal/cli/root_test.go +++ b/apps/daemon/internal/cli/root_test.go @@ -69,7 +69,6 @@ func TestSubcommandsAreRegistered(t *testing.T) { "status": false, "stop": false, "logs": false, - "logout": false, "version": false, } for _, c := range commands { diff --git a/apps/daemon/internal/cli/runtime_mcp_test.go b/apps/daemon/internal/cli/runtime_mcp_test.go index 9e592d142..20116ebbe 100644 --- a/apps/daemon/internal/cli/runtime_mcp_test.go +++ b/apps/daemon/internal/cli/runtime_mcp_test.go @@ -4,12 +4,13 @@ import ( "slices" "testing" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { - manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: proto.PluginMCPServer{ Name: "local", Type: "stdio", Command: "python3", Args: []string{"proof.py", "${LITERAL}"}, EnvVars: []string{"SELECTED"}, CWD: "./server", }}}} t.Setenv("SELECTED", "private-native-value") @@ -29,7 +30,7 @@ func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { } func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { - manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: proto.PluginMCPServer{ Name: "local", Type: "stdio", Command: "python3", CWD: "/workspace", }}}} got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil) diff --git a/apps/daemon/internal/cli/runtime_mcp_windows_test.go b/apps/daemon/internal/cli/runtime_mcp_windows_test.go index 8b17a1a66..193413aa7 100644 --- a/apps/daemon/internal/cli/runtime_mcp_windows_test.go +++ b/apps/daemon/internal/cli/runtime_mcp_windows_test.go @@ -4,14 +4,15 @@ package cli import ( "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "os" "os/exec" "path/filepath" "slices" "strings" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWindowsRuntimeMCPPackageManagers(t *testing.T) { @@ -137,7 +138,7 @@ func TestWindowsRuntimeMCPExplicitPath(t *testing.T) { t.Fatal(err) } t.Setenv("Path", os.Getenv("PATH")) - manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "test", Type: "stdio", Command: "selected-node", Args: []string{fixture}, CWD: directory, EnvVars: []string{"PATH", "OAC_MCP_FIXTURE_VALUE"}}}}} + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: proto.PluginMCPServer{Name: "test", Type: "stdio", Command: "selected-node", Args: []string{fixture}, CWD: directory, EnvVars: []string{"PATH", "OAC_MCP_FIXTURE_VALUE"}}}}} invocation, err := resolveMCPInvocation(manifest, directory, "plugins/0", "test", map[string]string{"PATH": directory, "OAC_MCP_FIXTURE_VALUE": "selected"}) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/cli/suspend_control.go b/apps/daemon/internal/cli/suspend_control.go index 9da262eeb..5cbc8312f 100644 --- a/apps/daemon/internal/cli/suspend_control.go +++ b/apps/daemon/internal/cli/suspend_control.go @@ -12,11 +12,11 @@ import ( "path/filepath" "syscall" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" ) -const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" - type suspendIdentity struct { PID int `json:"pid"` StartTime string `json:"start_time"` @@ -33,14 +33,16 @@ type suspendControl struct { signal chan os.Signal } -func newSuspendControl() (*suspendControl, error) { - path := os.Getenv(suspendControlEnv) - if path == "" { +// newSuspendControl publishes the control at runtimebootstrap.SuspendControlFile +// when the bootstrap enables it, and fails the connection when it cannot. +func newSuspendControl(enabled bool) (*suspendControl, error) { + if !enabled { return nil, nil } - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return nil, errors.New("connect: absolute suspend control file required") - } + return openSuspendControl(runtimebootstrap.SuspendControlFile) +} + +func openSuspendControl(path string) (*suspendControl, error) { info, err := os.Lstat(filepath.Dir(path)) if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { return nil, errors.New("connect: private suspend control directory required") @@ -49,10 +51,11 @@ func newSuspendControl() (*suspendControl, error) { if !ok || st.Uid != uint32(os.Getuid()) { return nil, errors.New("connect: owned suspend control directory required") } - environment := os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID") - if environment == "" { + local := localworkspace.Current() + if local == nil { return nil, errors.New("connect: suspend control requires an Environment binding") } + environment := local.Environment() start, err := suspendProcessStart(os.Getpid()) if err != nil { return nil, err diff --git a/apps/daemon/internal/cli/suspend_control_linux_test.go b/apps/daemon/internal/cli/suspend_control_linux_test.go index 83eaa1601..53d890936 100644 --- a/apps/daemon/internal/cli/suspend_control_linux_test.go +++ b/apps/daemon/internal/cli/suspend_control_linux_test.go @@ -10,7 +10,9 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" ) func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T) { @@ -19,15 +21,18 @@ func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T t.Fatal(err) } path := filepath.Join(dir, "control.json") - t.Setenv(suspendControlEnv, path) - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", "env") - control, err := newSuspendControl() + environment := uuid.NewString() + if err := localworkspace.Bind(environment, uuid.NewString(), t.TempDir(), t.TempDir()); err != nil { + t.Fatal(err) + } + t.Cleanup(localworkspace.Unbind) + control, err := openSuspendControl(path) if err != nil { t.Fatal(err) } defer control.Close() control.signal <- syscall.SIGUSR1 - if err := control.Arm(proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "current"}); err != nil { + if err := control.Arm(proto.EnvironmentSuspendPayload{EnvironmentID: environment, SuspendID: "current"}); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) @@ -35,7 +40,7 @@ func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T t.Fatal("stale signal woke future suspension") } cancel() - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "stale"}); err == nil { + if err := runResume(nil, []string{"--control-file", path, "--environment-id", environment, "--suspend-id", "stale"}); err == nil { t.Fatal("stale suspension accepted") } saved := control.identity.StartTime @@ -43,14 +48,14 @@ func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T if err := control.save(); err != nil { t.Fatal(err) } - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "current"}); err == nil { + if err := runResume(nil, []string{"--control-file", path, "--environment-id", environment, "--suspend-id", "current"}); err == nil { t.Fatal("reused PID accepted") } control.identity.StartTime = saved if err := control.save(); err != nil { t.Fatal(err) } - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "current"}); err != nil { + if err := runResume(nil, []string{"--control-file", path, "--environment-id", environment, "--suspend-id", "current"}); err != nil { t.Fatal(err) } ctx, cancel = context.WithTimeout(context.Background(), time.Second) @@ -61,7 +66,7 @@ func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T if err := control.Disarm(); err != nil { t.Fatal(err) } - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "current"}); err == nil { + if err := runResume(nil, []string{"--control-file", path, "--environment-id", environment, "--suspend-id", "current"}); err == nil { t.Fatal("completed suspension accepted another wake") } } diff --git a/apps/daemon/internal/cli/suspend_process_other.go b/apps/daemon/internal/cli/suspend_process_other.go index 5d57afa2d..3b7a69155 100644 --- a/apps/daemon/internal/cli/suspend_process_other.go +++ b/apps/daemon/internal/cli/suspend_process_other.go @@ -9,8 +9,6 @@ import ( "os" ) -const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" - type suspendIdentity struct { EnvironmentID string SuspendID string @@ -21,8 +19,8 @@ type suspendControl struct { lastResumed *proto.EnvironmentSuspendPayload } -func newSuspendControl() (*suspendControl, error) { - if os.Getenv(suspendControlEnv) != "" { +func newSuspendControl(enabled bool) (*suspendControl, error) { + if enabled { return nil, errors.New("hosted suspension requires Linux") } return nil, nil diff --git a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index ecfdde7a0..e5b6cddaf 100644 --- a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -103,6 +103,10 @@ 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 (*receiptCancelTurn) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + return agent.ErrUnsupportedOperation +} + func (t *receiptCancelTurn) SteerWithReceipt(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { written() close(t.written) diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 20abe7293..23720d271 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -228,6 +228,10 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } // These fixtures exercise settlement only; active input is deliberately rejected. +func (*terminalHandoffTurn) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + return agent.ErrUnsupportedOperation +} + func (*terminalHandoffTurn) SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error { return agent.ErrSteeringRejected } diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index de643a8f9..9058f6c40 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -56,6 +56,10 @@ func (t *reusableTurn) finish() { close(t.settled) }) } +func (*reusableTurn) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + return agent.ErrUnsupportedOperation +} + func (t *reusableTurn) Cancel(context.Context) error { t.cancels.Add(1); t.finish(); return nil } func (t *reusableTurn) CancellationOutcome() proto.DonePayload { return proto.DonePayload{Content: t.id, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} diff --git a/apps/daemon/internal/dispatch/functions.go b/apps/daemon/internal/dispatch/functions.go index 14252711b..d84716a7d 100644 --- a/apps/daemon/internal/dispatch/functions.go +++ b/apps/daemon/internal/dispatch/functions.go @@ -44,10 +44,6 @@ func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) e r.mu.Lock() state := r.sessions[env.ID] session, finishOperation, ready := r.preparedOperationLocked(state) - var submitter agent.FunctionResultSubmitter - if ready { - submitter, _ = session.(agent.FunctionResultSubmitter) - } r.mu.Unlock() if state != nil && !ready { return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "not_ready", "function call is waiting for the native session") @@ -61,13 +57,10 @@ func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) e if state != nil && !state.capabilities.FunctionTools.IsSupported() { return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "unsupported", "The runtime declaration does not support function results.") } - if ready && submitter == nil { - return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "contract_violation", "Declared function capability has no implementation.") - } - if submitter == nil { + if session == nil { return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "not_pending", "function call is no longer pending") } - if err := submitter.SubmitFunctionResult(ctx, result); err != nil { + if err := session.SubmitFunctionResult(ctx, result); err != nil { code := "runtime_error" if errors.Is(err, agent.ErrUnsupportedOperation) { code = "contract_violation" diff --git a/apps/daemon/internal/dispatch/native_file_results_test.go b/apps/daemon/internal/dispatch/native_file_results_test.go index dcc8b83bf..fda03adbb 100644 --- a/apps/daemon/internal/dispatch/native_file_results_test.go +++ b/apps/daemon/internal/dispatch/native_file_results_test.go @@ -3,6 +3,7 @@ package dispatch import ( "errors" "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" "github.com/google/uuid" "io/fs" @@ -14,13 +15,13 @@ func TestNativeDirectoryFailureMapping(t *testing.T) { err error outcome, code string }{ - {agent.ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, - {agent.ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, + {localworkspace.ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, + {localworkspace.ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, {fs.ErrNotExist, "rejected", "not_found"}, {fs.ErrPermission, "rejected", "permission_denied"}, - {agent.ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, + {localworkspace.ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, } { - got := workspaceReadResult(agent.WorkspaceReadResult{}, test.err, 0) + got := workspaceReadResult(localworkspace.WorkspaceReadResult{}, test.err, 0) if got.Outcome != test.outcome || got.ErrorCode != test.code { t.Fatal(test.err, got) } @@ -32,7 +33,7 @@ func TestLocalUploadUnknownRetainsOwner(t *testing.T) { if err != nil { t.Fatal(err) } - got := workspaceWriteResult(agent.WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) + got := workspaceWriteResult(localworkspace.WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) if got.Outcome != "unknown" { t.Fatal(got) } diff --git a/apps/daemon/internal/dispatch/preparation_cancel_test.go b/apps/daemon/internal/dispatch/preparation_cancel_test.go index 5804650cb..f3977123f 100644 --- a/apps/daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/daemon/internal/dispatch/preparation_cancel_test.go @@ -206,7 +206,7 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} p.start = func(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true} - out <- mustEnv(t, proto.TypeUsage, id, proto.UsagePayload{Usage: proto.Usage{InputTokens: 3}}) + out <- mustEnv(t, proto.TypeUsage, id, proto.UsagePayload{Usage: proto.Usage{Tokens: &proto.TokenUsage{InputTokens: 3}}}) close(entered) <-allowReturn return session, errors.New("controlled late Start failure") diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index 73206099f..c3e8c9ebf 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -113,9 +113,3 @@ func (t *preparationTurn) SteerWithReceipt(ctx context.Context, p proto.PromptSt } return agent.ErrSteeringInactive } -func (t *preparationTurn) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { - if target, ok := t.Session.(agent.WorkspaceReader); ok { - return target.ReadWorkspaceFile(ctx, path, limit) - } - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 28d532fd9..8a74242a1 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -14,9 +12,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "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/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) type controlledPreparation struct { @@ -74,22 +72,18 @@ func (p *controlledPreparation) CancellationOutcome() proto.DonePayload { const preparationEnvironmentID = "11111111-1111-4111-8111-111111111111" const preparationSessionID = "22222222-2222-4222-8222-222222222222" +// preparationDirectories are the workspace and capability directories of the latest preparationWorkspace binding. +var preparationDirectories struct{ workspace, capabilities string } + func preparationWorkspace(t *testing.T) *localworkspace.Binding { t.Helper() runtimeHome := t.TempDir() if err := os.Chmod(runtimeHome, 0o700); err != nil { t.Fatal(err) } - for name, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": preparationEnvironmentID, - "OAC_RUNTIME_SESSION_ID": preparationSessionID, - "OAC_RUNTIME_WORKSPACE": t.TempDir(), - "OAC_RUNTIME_CAPABILITY_DIRECTORY": t.TempDir(), - "OAC_RUNTIME_HOME": runtimeHome, - } { - t.Setenv(name, value) - } - binding, err := localworkspace.Load() + t.Setenv("OAC_RUNTIME_HOME", runtimeHome) + preparationDirectories.workspace, preparationDirectories.capabilities = t.TempDir(), t.TempDir() + binding, err := localworkspace.NewWithCapabilityDirectory(preparationEnvironmentID, preparationSessionID, preparationDirectories.workspace, preparationDirectories.capabilities) if err != nil { t.Fatal(err) } @@ -111,7 +105,7 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: proto.SessionStateKey(preparationSessionID), LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: proto.SessionStateKey(preparationSessionID), LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, WorkspaceDirectory: "/workspace", CapabilitySources: &proto.CapabilitySources{}}}} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 39c557936..a7615cf35 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -17,7 +17,6 @@ type preparedMutationSession struct { cancelOnce sync.Once functions atomic.Int32 steers atomic.Int32 - reads atomic.Int32 } func (s *preparedMutationSession) Cancel(ctx context.Context) error { @@ -35,11 +34,6 @@ func (s *preparedMutationSession) SteerWithReceipt(context.Context, proto.Prompt return nil } -func (s *preparedMutationSession) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - s.reads.Add(1) - return agent.WorkspaceReadResult{Data: []byte("x")}, nil -} - type blockingPreparedReceiptSender struct { *recSender deliveryID string diff --git a/apps/daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go index f366b649f..2d6e0ba0e 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -190,7 +190,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeWorkspaceReadResult || frames[len(frames)-1].DecodePayload(&readResult) != nil || readResult.ErrorCode != "resource_unavailable" { t.Fatalf("pre-publication workspace read = %+v", frames) } - if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 { + if session.functions.Load() != 0 || session.steers.Load() != 0 { t.Fatal("private Session accepted work before started publication") } duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")}) diff --git a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go index f6bb85f2a..cae0a9c0b 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -33,13 +33,13 @@ func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { calls.Add(1) return nil, errors.New("native factory must not be reached") }) - installation := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + installation := preparationDirectories.capabilities if info, err := os.Stat(installation); err != nil || !info.IsDir() { t.Fatalf("capability fixture is unavailable: %v", err) } request := preparationRequest() request.Configuration.LocalEnvironment.Capabilities = true - request.Configuration.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + request.Configuration.LocalEnvironment.CapabilitySources = &proto.CapabilitySources{Directories: []string{source}} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "missing-capability", request)); err != nil { t.Fatalf("valid frozen selection rejected before preparation: %v", err) } diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index ba8631a86..cdd948ae6 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -16,7 +16,6 @@ import ( "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/agentskill" "github.com/google/uuid" ) @@ -59,7 +58,7 @@ func capabilityBegin(environment, session string, body []byte) proto.RuntimePrep digest := sha256.Sum256(body) return proto.RuntimePreparePayload{ BudgetMS: 300000, Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session, - Skill: &agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."}, + Skill: &proto.SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, SizeBytes: len(body), SHA256: hex.EncodeToString(digest[:]), } } @@ -249,7 +248,7 @@ func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing. sender.frames = make(chan proto.Envelope) ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() - request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &proto.CapabilitySources{}} owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) r.runtimePreparation = owner diff --git a/apps/daemon/internal/dispatch/workspace_directory_test.go b/apps/daemon/internal/dispatch/workspace_directory_test.go index 1c584484f..f4bd00ecf 100644 --- a/apps/daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/daemon/internal/dispatch/workspace_directory_test.go @@ -20,7 +20,7 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { } r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) for _, name := range []string{"file", "second"} { - if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(preparationDirectories.workspace, name), []byte("abc"), 0600); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/dispatch/workspace_export.go b/apps/daemon/internal/dispatch/workspace_export.go index ba0e70578..d47de6580 100644 --- a/apps/daemon/internal/dispatch/workspace_export.go +++ b/apps/daemon/internal/dispatch/workspace_export.go @@ -58,7 +58,7 @@ func (r *Router) handleWorkspaceExport(ctx context.Context, env proto.Envelope) return errors.New("dispatch: workspace export request already pending") } } - _, code := r.workspaceResourceLocked(proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) + code := r.workspaceReadAdmissionLocked(proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) p := r.preparations[request.Handle] if u != nil || r.workspaceWrite != nil || !r.localWorkspace.CanExport() || code != "" || p == nil || p.executor != nil { r.mu.Unlock() diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go index 7511a84c5..b82d22bab 100644 --- a/apps/daemon/internal/dispatch/workspace_read.go +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "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" ) @@ -36,7 +36,7 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er r.mu.Unlock() return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("read_capacity")) } - resource, code := r.workspaceResourceLocked(request) + code := r.workspaceReadAdmissionLocked(request) if code != "" { r.mu.Unlock() return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead(code)) @@ -45,6 +45,7 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er r.workspaceReads = make(map[string]struct{}) } r.workspaceReads[env.ID] = struct{}{} + binding := r.localWorkspace r.shutdownWG.Add(1) r.mu.Unlock() go func() { @@ -53,64 +54,38 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er // Observer loss does not discard an admitted native wait or replay it. operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 12*time.Second) defer cancel() - result := executeWorkspaceRead(operation, resource, request) + result := executeWorkspaceRead(operation, binding, request) _ = r.sendWorkspaceRead(context.WithoutCancel(ctx), env, result) }() return nil } -func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (any, string) { - var resource any +func (r *Router) workspaceReadAdmissionLocked(request proto.WorkspaceReadPayload) string { if request.Handle != "" { p := r.preparations[request.Handle] if p == nil || p.environmentID != request.EnvironmentID || p.status.State != "ready" || !p.owns || p.busy || p.ctx.Err() != nil || !time.Now().Before(p.deadline) { - return nil, "resource_unavailable" + return "resource_unavailable" } - if p.executor != nil { - resource = p.executor.native - } - } else { - s := r.sessions[request.RunID] - if s == nil || s.environmentID != request.EnvironmentID || s.session == nil || - !r.interactionRouteOpenLocked(s) { - return nil, "resource_unavailable" - } - resource = s.session + return "" } - if r.localWorkspace != nil { - resource = r.localWorkspace + s := r.sessions[request.RunID] + if s == nil || s.environmentID != request.EnvironmentID || s.session == nil || + !r.interactionRouteOpenLocked(s) { + return "resource_unavailable" } - return resource, "" + return "" } -func executeWorkspaceRead(ctx context.Context, resource any, request proto.WorkspaceReadPayload) proto.WorkspaceReadResultPayload { +func executeWorkspaceRead(ctx context.Context, binding *localworkspace.Binding, request proto.WorkspaceReadPayload) proto.WorkspaceReadResultPayload { if request.Operation == "directory" { - reader, ok := resource.(agent.WorkspaceDirectoryLister) - if !ok { - return rejectedWorkspaceRead("read_unsupported") - } - read, err := reader.ListWorkspaceDirectory(ctx, request.Path, request.MaxEntries) + read, err := binding.ListWorkspaceDirectory(ctx, request.Path, request.MaxEntries) if err != nil { - return workspaceReadResult(agent.WorkspaceReadResult{}, err, 0) - } - if read.Entries == nil || len(read.Entries) > request.MaxEntries { - return workspaceReadResult(agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain, 0) + return workspaceReadResult(localworkspace.WorkspaceReadResult{}, err, 0) } - directory := &proto.WorkspaceDirectoryResult{Entries: make([]proto.WorkspaceDirectoryEntry, 0, len(read.Entries)), Truncated: read.Truncated} - for _, entry := range read.Entries { - directory.Entries = append(directory.Entries, proto.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) - } - if !proto.ValidWorkspaceDirectory(directory, request.MaxEntries) { - return workspaceReadResult(agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain, 0) - } - return proto.WorkspaceReadResultPayload{Outcome: "completed", Directory: directory, CloseAcknowledged: true} - } - reader, ok := resource.(agent.WorkspaceReader) - if !ok { - return rejectedWorkspaceRead("read_unsupported") + return proto.WorkspaceReadResultPayload{Outcome: "completed", Directory: read, CloseAcknowledged: true} } - read, err := reader.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) + read, err := binding.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) return workspaceReadResult(read, err, request.MaxBytes) } @@ -118,7 +93,7 @@ func rejectedWorkspaceRead(code string) proto.WorkspaceReadResultPayload { return proto.WorkspaceReadResultPayload{Outcome: "rejected", ErrorCode: code} } -func workspaceReadResult(read agent.WorkspaceReadResult, err error, limit int) proto.WorkspaceReadResultPayload { +func workspaceReadResult(read localworkspace.WorkspaceReadResult, err error, limit int) proto.WorkspaceReadResultPayload { if err == nil && len(read.Data) <= limit && (!read.Truncated || len(read.Data) == limit) { return proto.WorkspaceReadResultPayload{Outcome: "completed", Data: read.Data, Truncated: read.Truncated, CloseAcknowledged: true} } @@ -126,11 +101,10 @@ func workspaceReadResult(read agent.WorkspaceReadResult, err error, limit int) p err error code string }{ - {agent.ErrWorkspaceReadUnsupported, "read_unsupported"}, - {agent.ErrWorkspaceReadUnavailable, "resource_unavailable"}, - {agent.ErrWorkspaceReadBusy, "read_capacity"}, - {agent.ErrWorkspaceReadInvalid, "invalid_request"}, - {agent.ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory}, + {localworkspace.ErrWorkspaceReadUnavailable, "resource_unavailable"}, + {localworkspace.ErrWorkspaceReadBusy, "read_capacity"}, + {localworkspace.ErrWorkspaceReadInvalid, "invalid_request"}, + {localworkspace.ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory}, {fs.ErrNotExist, "not_found"}, {fs.ErrPermission, "permission_denied"}, } { diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index 7aa41f555..50ef6c5ff 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -7,7 +7,7 @@ import ( "errors" "time" - "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" "github.com/google/uuid" ) @@ -139,7 +139,7 @@ func rejectedWorkspaceWrite(code string) proto.WorkspaceWriteResultPayload { return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: code} } -func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) proto.WorkspaceWriteResultPayload { +func workspaceWriteResult(write localworkspace.WorkspaceWriteResult, err error, size int) proto.WorkspaceWriteResultPayload { if err == nil && write.SizeBytes == int64(size) { return proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: size} } @@ -147,8 +147,8 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) err error reason string }{ - {agent.ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory}, - {agent.ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe}, + {localworkspace.ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory}, + {localworkspace.ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe}, } { if errors.Is(err, conflict.err) { return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: conflict.reason} @@ -158,11 +158,11 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) err error code string }{ - {agent.ErrWorkspaceWriteUnsupported, "write_unsupported"}, - {agent.ErrWorkspaceWriteUnavailable, "resource_unavailable"}, - {agent.ErrWorkspaceWriteBusy, "write_capacity"}, - {agent.ErrWorkspaceWriteInvalid, "invalid_request"}, - {agent.ErrWorkspaceWriteRejected, "write_rejected"}, + {localworkspace.ErrWorkspaceWriteUnsupported, "write_unsupported"}, + {localworkspace.ErrWorkspaceWriteUnavailable, "resource_unavailable"}, + {localworkspace.ErrWorkspaceWriteBusy, "write_capacity"}, + {localworkspace.ErrWorkspaceWriteInvalid, "invalid_request"}, + {localworkspace.ErrWorkspaceWriteRejected, "write_rejected"}, } { if errors.Is(err, failure.err) { return rejectedWorkspaceWrite(failure.code) diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index 93a6a68d9..d94f26576 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -2,12 +2,12 @@ package localworkspace import ( "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "os" - "strings" "sync" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -37,22 +37,30 @@ func NewWithCapabilityDirectory(environment, session, workspace, directory strin return b, nil } -func Load() (*Binding, error) { - values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE")} - capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if strings.Join(values, "") == "" && capabilityDirectory == "" { - return nil, nil - } - if capabilityDirectory == "" { - capabilityDirectory = CapabilityDirectory - } - b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], capabilityDirectory) +var current *Binding + +// Bind freezes the Runtime's Environment binding for this process. It runs once, before any Harness discovery. +func Bind(environment, session, workspace, capabilityDirectory string) error { + b, err := NewWithCapabilityDirectory(environment, session, workspace, capabilityDirectory) if err != nil { - return nil, err + return err } - return b, nil + current = b + return nil } +// Unbind clears the Binding set by Bind. +func Unbind() { current = nil } + +// Current returns the Binding set by Bind, or nil when the Runtime serves no dedicated Environment. +func Current() *Binding { return current } + +// Workspace is the directory bound as the Environment's workspace. +func (b *Binding) Workspace() string { return b.workspace } + +// Environment is the bound Environment ID. +func (b *Binding) Environment() string { return b.environment } + // Configure validates the reference before supplying the immutable local cwd. func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { if b == nil && r.LocalEnvironment == nil { @@ -64,10 +72,10 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if !r.WorkspaceReadOnly { local := *r.LocalEnvironment - if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { + if local.WorkspaceDirectory != runtimebootstrap.PublicWorkspace && local.WorkspaceDirectory != b.workspace { return r, errors.New("request does not match the local workspace selection") } - if local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil { + if local.CapabilitySources == nil || proto.ValidateCapabilitySources(*local.CapabilitySources) != nil { return r, agentcapabilities.ErrInvalid } sources := *local.CapabilitySources diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index 06ba291fe..31bfea2fc 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -24,7 +24,7 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { if err != nil { t.Fatal(err) } - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: proto.SessionStateKey(session)} + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &proto.CapabilitySources{}}, AgentStateKey: proto.SessionStateKey(session)} } func TestBindingRejectsScopeOverrides(t *testing.T) { diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index 096933020..fc1558c7f 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -3,6 +3,7 @@ package localworkspace import ( "context" "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "os" "path/filepath" "strings" @@ -73,7 +74,6 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr local.MCP, err = resolveEnvironmentMCP(manifest.MCP, values) for i := range local.MCP { local.MCP[i].InstallationRoot = b.capabilityRoot - local.MCP[i].WorkspaceRoot = b.workspace } if err != nil { return r, err @@ -141,7 +141,7 @@ func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.Runti return ctx.Err() } -func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities.Input, identity agentcapabilities.Identity, completed bool) (agentcapabilities.Manifest, error) { +func (b *Binding) loadCapabilitySnapshot(root *os.Root, input proto.CapabilitySources, identity agentcapabilities.Identity, completed bool) (agentcapabilities.Manifest, error) { if _, err := root.Lstat(agentcapabilities.ManifestName); errors.Is(err, os.ErrNotExist) { if completed || agentcapabilities.Finalize(root, input, identity, b.resolveCapabilityDirectory) != nil { return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid @@ -199,8 +199,8 @@ func (b *Binding) resolveCapabilityDirectory(source string) (*os.Root, error) { if agentcapabilities.ValidateLocalDirectories([]string{source}) != nil { return nil, agentcapabilities.ErrInvalid } - if source == "/workspace" || strings.HasPrefix(source, "/workspace/") { - source = filepath.Join(b.workspace, strings.TrimPrefix(source, "/workspace")) + if source == runtimebootstrap.PublicWorkspace || strings.HasPrefix(source, runtimebootstrap.PublicWorkspace+"/") { + source = filepath.Join(b.workspace, strings.TrimPrefix(source, runtimebootstrap.PublicWorkspace)) } // Refuse recursive installation into the selected source itself. if containsPath(source, b.capabilityRoot) || containsPath(b.capabilityRoot, source) { diff --git a/apps/daemon/internal/localworkspace/capabilities_test.go b/apps/daemon/internal/localworkspace/capabilities_test.go index 224213fca..66d74128c 100644 --- a/apps/daemon/internal/localworkspace/capabilities_test.go +++ b/apps/daemon/internal/localworkspace/capabilities_test.go @@ -5,14 +5,13 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { binding, request := testBinding(t) request.LocalEnvironment.CapabilityRoot = "/caller/installation" - request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller", InstallationRoot: "/caller/installation"}} + request.LocalEnvironment.Skills = []proto.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller", InstallationRoot: "/caller/installation"}} secret := "private-mcp-marker" request.LocalEnvironment.MCP = []proto.EnvironmentMCP{{PackageRoot: "caller/private", BearerToken: &secret}} raw, err := json.Marshal(request.LocalEnvironment) diff --git a/apps/daemon/internal/localworkspace/capability_preparation_test.go b/apps/daemon/internal/localworkspace/capability_preparation_test.go index 8bb807e19..06cd5f368 100644 --- a/apps/daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/daemon/internal/localworkspace/capability_preparation_test.go @@ -27,7 +27,7 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { source := t.TempDir() writeSourceSkill(t, source, "first") req.LocalEnvironment.Capabilities = true - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + req.LocalEnvironment.CapabilitySources = &proto.CapabilitySources{Directories: []string{source}} configured, err := b.Configure(req) if err != nil { t.Fatal(err) @@ -69,7 +69,7 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { // Reusing a snapshot under another selection cannot start native work, even // for another Session. local := *configured.LocalEnvironment - local.CapabilitySources = &agentcapabilities.Input{} + local.CapabilitySources = &proto.CapabilitySources{} changed := configured changed.LocalEnvironment = &local if _, err = b.Prepare(t.Context(), changed); err == nil { @@ -104,7 +104,7 @@ func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { } root.Close() req.LocalEnvironment.Capabilities = true - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source, t.TempDir()}} + req.LocalEnvironment.CapabilitySources = &proto.CapabilitySources{Directories: []string{source, t.TempDir()}} configured, err := b.Configure(req) if 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 index e1f4cbd3a..a0a10ebfd 100644 --- a/apps/daemon/internal/localworkspace/capability_rebind_test.go +++ b/apps/daemon/internal/localworkspace/capability_rebind_test.go @@ -3,6 +3,8 @@ package localworkspace import ( "testing" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/google/uuid" ) @@ -11,7 +13,7 @@ func TestFinalizeRebindsIdenticalRestoredInstallation(t *testing.T) { first, _ := testBinding(t) source := t.TempDir() writeSourceSkill(t, source, "restored") - input := agentcapabilities.Input{Directories: []string{source}} + input := proto.CapabilitySources{Directories: []string{source}} root, unlock, err := first.openCapabilities(t.Context(), false) if err != nil { t.Fatal(err) @@ -25,7 +27,7 @@ func TestFinalizeRebindsIdenticalRestoredInstallation(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = next.loadCapabilitySnapshot(root, agentcapabilities.Input{}, next.capabilityIdentity(), false); err == nil { + if _, err = next.loadCapabilitySnapshot(root, proto.CapabilitySources{}, next.capabilityIdentity(), false); err == nil { t.Fatal("different selection accepted") } if _, err = next.loadCapabilitySnapshot(root, input, next.capabilityIdentity(), false); err != nil { diff --git a/apps/daemon/internal/localworkspace/directory.go b/apps/daemon/internal/localworkspace/directory.go index 3104ed8ef..b40a7decb 100644 --- a/apps/daemon/internal/localworkspace/directory.go +++ b/apps/daemon/internal/localworkspace/directory.go @@ -5,13 +5,12 @@ import ( "io/fs" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { +func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (*proto.WorkspaceDirectoryResult, error) { if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid + return nil, ErrWorkspaceReadInvalid } return b.listNativeDirectory(ctx, path, limit) } diff --git a/apps/daemon/internal/localworkspace/directory_native_test.go b/apps/daemon/internal/localworkspace/directory_native_test.go index ef22420f6..4f0b5ffc4 100644 --- a/apps/daemon/internal/localworkspace/directory_native_test.go +++ b/apps/daemon/internal/localworkspace/directory_native_test.go @@ -2,7 +2,6 @@ package localworkspace import ( "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "os" "path/filepath" "testing" @@ -21,7 +20,7 @@ func TestNativeDirectoryAPI(t *testing.T) { t.Fatal(got, err) } for _, path := range []string{"missing", "nested/data.bin"} { - if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, agent.ErrWorkspaceNotDirectory) { + if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, ErrWorkspaceNotDirectory) { t.Fatal(path, err) } } @@ -47,7 +46,7 @@ func TestNativeFileReadPrefixAndEntryKinds(t *testing.T) { if err != nil || got.Truncated || len(got.Data) != 3 { t.Fatal(got, err) } - if _, err = b.ReadWorkspaceFile(t.Context(), "nested", 3); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err = b.ReadWorkspaceFile(t.Context(), "nested", 3); !errors.Is(err, ErrWorkspaceReadInvalid) { t.Fatal(err) } if _, err = b.ReadWorkspaceFile(t.Context(), "missing", 3); !os.IsNotExist(err) { diff --git a/apps/daemon/internal/localworkspace/initialization.go b/apps/daemon/internal/localworkspace/initialization.go index 4d99c1d40..b3e06af37 100644 --- a/apps/daemon/internal/localworkspace/initialization.go +++ b/apps/daemon/internal/localworkspace/initialization.go @@ -70,30 +70,14 @@ func validToolEnvironment(values map[string]string) bool { // ReadOptionalToolEnvironment permits a Runtime without explicit user variables. func ReadOptionalToolEnvironment() (map[string]string, error) { - values, _, err := readOptionalToolEnvironment() - return values, err -} - -// ToolEnvironmentFile returns the validated Runtime-owned source without copying -// its values into a Harness profile. An unconfigured environment has no file. -func ToolEnvironmentFile() (string, error) { - _, path, err := readOptionalToolEnvironment() - return path, err -} - -func readOptionalToolEnvironment() (map[string]string, string, error) { path, err := toolEnvironmentPath() if err != nil { - return nil, "", err + return nil, err } if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) && os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE") == "" { - return map[string]string{}, "", nil - } - values, err := readToolEnvironmentFile(path) - if err != nil { - return nil, "", err + return map[string]string{}, nil } - return values, path, nil + return readToolEnvironmentFile(path) } // The prepared snapshot takes precedence over the operator's source file. diff --git a/apps/daemon/internal/localworkspace/mcp_test.go b/apps/daemon/internal/localworkspace/mcp_test.go index 1ff8b336b..130d58810 100644 --- a/apps/daemon/internal/localworkspace/mcp_test.go +++ b/apps/daemon/internal/localworkspace/mcp_test.go @@ -7,12 +7,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { t.Setenv("PLUGIN_TOKEN", "native-private-value") - installed := []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + installed := []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: proto.PluginMCPServer{ Name: "remote", Type: "http", URL: "https://example.com/mcp", BearerTokenEnvVar: "PLUGIN_TOKEN", }}} if _, err := resolveEnvironmentMCP(installed, nil); err == nil { @@ -37,7 +36,7 @@ func TestDeclaredMCPVariablesNeverFallBackToNativeEnv(t *testing.T) { } func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { - server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/0", Server: proto.PluginMCPServer{ Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, }} command, args := MCPStdioCommand(server) diff --git a/apps/daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go index 7748ca783..c8a0bdf86 100644 --- a/apps/daemon/internal/localworkspace/native_files.go +++ b/apps/daemon/internal/localworkspace/native_files.go @@ -11,7 +11,6 @@ import ( "sort" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -28,18 +27,17 @@ func nativeAPIPath(path string) (string, error) { return filepath.Localize(path) } -func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { - result := agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{}} +func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (*proto.WorkspaceDirectoryResult, error) { if b == nil || ctx.Err() != nil { - return result, agent.ErrWorkspaceReadUnavailable + return nil, ErrWorkspaceReadUnavailable } local, err := nativeAPIPath(path) if err != nil { - return result, agent.ErrWorkspaceReadInvalid + return nil, ErrWorkspaceReadInvalid } root, err := os.OpenRoot(b.workspace) if err != nil { - return result, agent.ErrWorkspaceReadUnavailable + return nil, ErrWorkspaceReadUnavailable } defer root.Close() if local != "." { @@ -48,38 +46,38 @@ func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit in partial = filepath.Join(partial, part) info, err := root.Lstat(partial) if errors.Is(err, fs.ErrPermission) { - return result, fs.ErrPermission + return nil, fs.ErrPermission } if err != nil || !info.IsDir() { - return result, agent.ErrWorkspaceNotDirectory + return nil, ErrWorkspaceNotDirectory } } } dir, err := openNativePath(root, local) if errors.Is(err, fs.ErrPermission) { - return result, fs.ErrPermission + return nil, fs.ErrPermission } if err != nil { - return result, agent.ErrWorkspaceNotDirectory + return nil, ErrWorkspaceNotDirectory } defer dir.Close() entries, err := dir.ReadDir(limit + 1) if err != nil && err != io.EOF { - return result, agent.ErrWorkspaceNotDirectory + return nil, ErrWorkspaceNotDirectory } if ctx.Err() != nil { - return result, agent.ErrWorkspaceReadUnavailable + return nil, ErrWorkspaceReadUnavailable } - result.Truncated = len(entries) > limit - if result.Truncated { + truncated := len(entries) > limit + if truncated { entries = entries[:limit] } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) - wire := &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}, Truncated: result.Truncated} + result := &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}, Truncated: truncated} for _, entry := range entries { info, err := entry.Info() if err != nil { - return result, agent.ErrWorkspaceReadUncertain + return nil, ErrWorkspaceReadUncertain } item := proto.WorkspaceDirectoryEntry{Name: entry.Name(), Kind: "other"} switch { @@ -92,26 +90,23 @@ func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit in case info.Mode()&os.ModeSymlink != 0: item.Kind = "symlink" } - wire.Entries = append(wire.Entries, item) - } - if !proto.ValidWorkspaceDirectory(wire, limit) { - return result, agent.ErrWorkspaceReadInvalid + result.Entries = append(result.Entries, item) } - for _, item := range wire.Entries { - result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: item.Name, Kind: item.Kind, SizeBytes: item.SizeBytes}) + if !proto.ValidWorkspaceDirectory(result, limit) { + return nil, ErrWorkspaceReadInvalid } return result, nil } -func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) (agent.WorkspaceWriteResult, error) { - result := agent.WorkspaceWriteResult{} +func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) (WorkspaceWriteResult, error) { + result := WorkspaceWriteResult{} local, err := nativeAPIPath(path) if err != nil { - return result, agent.ErrWorkspaceWriteInvalid + return result, ErrWorkspaceWriteInvalid } root, err := os.OpenRoot(b.workspace) if err != nil { - return result, agent.ErrWorkspaceWriteUnavailable + return result, ErrWorkspaceWriteUnavailable } defer root.Close() if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { @@ -135,24 +130,24 @@ func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) if err = root.Link(temporary, local); err != nil { if info, e := root.Lstat(local); e == nil { if info.IsDir() { - return result, agent.ErrWorkspaceWriteDirectory + return result, ErrWorkspaceWriteDirectory } - return result, agent.ErrWorkspaceWriteUnsafe + return result, ErrWorkspaceWriteUnsafe } else if errors.Is(e, fs.ErrNotExist) { return result, unpublishedWriteError(err) } - return result, agent.ErrWorkspaceWriteRejected + return result, ErrWorkspaceWriteRejected } - return agent.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil + return WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil } // unpublishedWriteError applies only before this operation publishes a file. // Parent directories or a temporary file may already have been created. func unpublishedWriteError(err error) error { if storageExhausted(err) { - return agent.ErrWorkspaceWriteUnavailable + return ErrWorkspaceWriteUnavailable } - return agent.ErrWorkspaceWriteRejected + return ErrWorkspaceWriteRejected } const artifactFileBytes int64 = 200 << 20 @@ -291,21 +286,21 @@ func (r nativeContextReader) Read(data []byte) (int, error) { // ReadWorkspaceFile returns the existing protocol's bounded prefix; the wire // has no offset field. Non-regular entries cannot become unbounded streams. -func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { - result := agent.WorkspaceReadResult{} +func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) (WorkspaceReadResult, error) { + result := WorkspaceReadResult{} if b == nil || ctx.Err() != nil { - return result, agent.ErrWorkspaceReadUnavailable + return result, ErrWorkspaceReadUnavailable } if path == "" || limit < 1 || limit > proto.WorkspaceReadMaxBytes { - return result, agent.ErrWorkspaceReadInvalid + return result, ErrWorkspaceReadInvalid } local, err := nativeAPIPath(path) if err != nil { - return result, agent.ErrWorkspaceReadInvalid + return result, ErrWorkspaceReadInvalid } root, err := os.OpenRoot(b.workspace) if err != nil { - return result, agent.ErrWorkspaceReadUnavailable + return result, ErrWorkspaceReadUnavailable } defer root.Close() info, err := root.Lstat(local) @@ -313,7 +308,7 @@ func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) return result, err } if !info.Mode().IsRegular() { - return result, agent.ErrWorkspaceReadInvalid + return result, ErrWorkspaceReadInvalid } file, err := openNativePath(root, local) if err != nil { @@ -322,14 +317,14 @@ func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) defer file.Close() info, err = file.Stat() if err != nil { - return result, agent.ErrWorkspaceReadUncertain + return result, ErrWorkspaceReadUncertain } if !info.Mode().IsRegular() { - return result, agent.ErrWorkspaceReadInvalid + return result, ErrWorkspaceReadInvalid } raw, err := io.ReadAll(io.LimitReader(nativeContextReader{ctx: ctx, reader: file}, int64(limit)+1)) if err != nil { - return result, agent.ErrWorkspaceReadUncertain + return result, ErrWorkspaceReadUncertain } result.Truncated = len(raw) > limit if result.Truncated { diff --git a/apps/daemon/internal/localworkspace/native_write_error_unix_test.go b/apps/daemon/internal/localworkspace/native_write_error_unix_test.go index 9c4b62370..20e89635a 100644 --- a/apps/daemon/internal/localworkspace/native_write_error_unix_test.go +++ b/apps/daemon/internal/localworkspace/native_write_error_unix_test.go @@ -8,20 +8,18 @@ import ( "os" "syscall" "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) func TestNativeUnpublishedWriteStorageFailure(t *testing.T) { for _, cause := range []error{syscall.ENOSPC, syscall.EDQUOT} { for _, err := range []error{cause, &os.PathError{Op: "write", Path: "private/path", Err: cause}, fmt.Errorf("sync failed: %w", cause), errors.Join(syscall.EIO, cause)} { - if got := unpublishedWriteError(err); got != agent.ErrWorkspaceWriteUnavailable { + if got := unpublishedWriteError(err); got != ErrWorkspaceWriteUnavailable { t.Fatalf("storage exhaustion: got %v, want unavailable", got) } } } for _, cause := range []error{syscall.EIO, syscall.EACCES, syscall.EEXIST, syscall.ENOTDIR} { - if got := unpublishedWriteError(&os.PathError{Op: "write", Path: "private/path", Err: cause}); got != agent.ErrWorkspaceWriteRejected { + if got := unpublishedWriteError(&os.PathError{Op: "write", Path: "private/path", Err: cause}); got != ErrWorkspaceWriteRejected { t.Fatalf("changed ordinary rejection: %v", got) } } diff --git a/apps/daemon/internal/localworkspace/native_write_error_windows_test.go b/apps/daemon/internal/localworkspace/native_write_error_windows_test.go index c250a0cd3..bbcd5b8bd 100644 --- a/apps/daemon/internal/localworkspace/native_write_error_windows_test.go +++ b/apps/daemon/internal/localworkspace/native_write_error_windows_test.go @@ -4,17 +4,16 @@ import ( "os" "testing" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "golang.org/x/sys/windows" ) func TestNativeUnpublishedWriteStorageFailure(t *testing.T) { for _, cause := range []error{windows.ERROR_DISK_FULL, windows.ERROR_HANDLE_DISK_FULL, windows.ERROR_DISK_QUOTA_EXCEEDED} { - if got := unpublishedWriteError(&os.PathError{Op: "write", Path: "private/path", Err: cause}); got != agent.ErrWorkspaceWriteUnavailable { + if got := unpublishedWriteError(&os.PathError{Op: "write", Path: "private/path", Err: cause}); got != ErrWorkspaceWriteUnavailable { t.Fatalf("storage exhaustion: got %v, want unavailable", got) } } - if got := unpublishedWriteError(windows.ERROR_ACCESS_DENIED); got != agent.ErrWorkspaceWriteRejected { + if got := unpublishedWriteError(windows.ERROR_ACCESS_DENIED); got != ErrWorkspaceWriteRejected { t.Fatalf("changed ordinary rejection: %v", got) } } diff --git a/apps/daemon/internal/agent/workspace_write.go b/apps/daemon/internal/localworkspace/results.go similarity index 55% rename from apps/daemon/internal/agent/workspace_write.go rename to apps/daemon/internal/localworkspace/results.go index e584843f8..45639c64c 100644 --- a/apps/daemon/internal/agent/workspace_write.go +++ b/apps/daemon/internal/localworkspace/results.go @@ -1,16 +1,33 @@ -package agent +package localworkspace import ( "errors" "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) +type WorkspaceReadResult struct { + Data []byte + Truncated bool +} + type WorkspaceWriteResult struct { SizeBytes int64 } var ( - ErrWorkspaceWriteUnsupported = fmt.Errorf("%w: workspace write", ErrUnsupportedOperation) + ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") + ErrWorkspaceReadBusy = errors.New("workspace read busy") + ErrWorkspaceReadInvalid = errors.New("workspace read invalid") + ErrWorkspaceReadUncertain = errors.New("workspace read outcome uncertain") + // ErrWorkspaceNotDirectory reports that a directory request's own path is + // missing, a regular file or a symbolic link; the link was not followed. + ErrWorkspaceNotDirectory = errors.New("workspace path is not a directory") +) + +var ( + ErrWorkspaceWriteUnsupported = fmt.Errorf("%w: workspace write", agent.ErrUnsupportedOperation) ErrWorkspaceWriteUnavailable = errors.New("workspace write unavailable") ErrWorkspaceWriteBusy = errors.New("workspace write busy") ErrWorkspaceWriteInvalid = errors.New("workspace write invalid") diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go index 2db03a780..0415f4f4c 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "os" "path/filepath" "runtime" @@ -85,13 +86,13 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit } func (b *Binding) initializationCWD(value string) (string, error) { - if value == "" || value == "/workspace" { + if value == "" || value == runtimebootstrap.PublicWorkspace { return b.workspace, nil } - if !strings.HasPrefix(value, "/workspace/") { + if !strings.HasPrefix(value, runtimebootstrap.PublicWorkspace+"/") { return "", agentcapabilities.ErrInvalid } - relative, err := nativeAPIPath(strings.TrimPrefix(value, "/workspace/")) + relative, err := nativeAPIPath(strings.TrimPrefix(value, runtimebootstrap.PublicWorkspace+"/")) if err != nil { return "", agentcapabilities.ErrInvalid } @@ -177,10 +178,10 @@ func configureRuntime(values map[string]string) error { } func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { - if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { + if b.writer == nil || !strings.HasPrefix(input.Path, runtimebootstrap.PublicWorkspace+"/") || len(data) > proto.RuntimePrepareMaxBytes { return agentcapabilities.ErrInvalid } - relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, "/workspace/")) + relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, runtimebootstrap.PublicWorkspace+"/")) if err != nil { return agentcapabilities.ErrInvalid } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_process.go b/apps/daemon/internal/localworkspace/runtime_initialization_process.go index a8464ed97..f1ab774f1 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_process.go @@ -25,9 +25,7 @@ func initializationBash() (string, error) { // Git Bash preserves setup's Bash syntax. Never substitute cmd.exe, PowerShell // or the Windows WSL launcher for a missing Bash implementation. candidates := []string{} - if explicit := os.Getenv("CLAUDE_CODE_GIT_BASH_PATH"); explicit != "" { - candidates = append(candidates, explicit) - } else if git, err := exec.LookPath("git.exe"); err == nil { + if git, err := exec.LookPath("git.exe"); err == nil { candidates = append(candidates, filepath.Join(filepath.Dir(git), "..", "bin", "bash.exe"), filepath.Join(filepath.Dir(git), "bash.exe")) } for _, candidate := range candidates { diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go index 47896582a..4e4aeb2bf 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -257,7 +257,6 @@ func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T) { b := initializationFixture(t) t.Setenv("PATH", t.TempDir()) - t.Setenv("CLAUDE_CODE_GIT_BASH_PATH", "") if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/skills.go b/apps/daemon/internal/localworkspace/skills.go index f6bbf4d98..47a8682c7 100644 --- a/apps/daemon/internal/localworkspace/skills.go +++ b/apps/daemon/internal/localworkspace/skills.go @@ -3,11 +3,13 @@ package localworkspace import ( "path/filepath" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" ) const CapabilityDirectory = agentcapabilities.Directory -func SkillPath(skill agentcapabilities.InstalledSkill) string { +func SkillPath(skill proto.InstalledSkill) string { return filepath.Join(skill.InstallationRoot, skill.RelativeRoot) } diff --git a/apps/daemon/internal/localworkspace/skills_test.go b/apps/daemon/internal/localworkspace/skills_test.go index be37c0358..388524b0b 100644 --- a/apps/daemon/internal/localworkspace/skills_test.go +++ b/apps/daemon/internal/localworkspace/skills_test.go @@ -1,14 +1,15 @@ package localworkspace import ( - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "path/filepath" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSkillPathUsesRuntimeInstallationRoot(t *testing.T) { root := t.TempDir() - skill := agentcapabilities.InstalledSkill{InstallationRoot: root, RelativeRoot: "plugins/0/skills/proof"} + skill := proto.InstalledSkill{InstallationRoot: root, RelativeRoot: "plugins/0/skills/proof"} if got := SkillPath(skill); got != filepath.Join(root, "plugins/0/skills/proof") { t.Fatal("Runtime root lost", got) } diff --git a/apps/daemon/internal/localworkspace/snapshot_marker_test.go b/apps/daemon/internal/localworkspace/snapshot_marker_test.go index 90132e1e5..5126ade53 100644 --- a/apps/daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker_test.go @@ -10,7 +10,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) func markerBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { @@ -78,7 +77,7 @@ func TestCompletedSnapshotLossNeverRecapturesSources(t *testing.T) { if populated { writeSourceSkill(t, source, "first") request.LocalEnvironment.Capabilities = true - request.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + request.LocalEnvironment.CapabilitySources = &proto.CapabilitySources{Directories: []string{source}} } if _, err := b.Prepare(t.Context(), request); err != nil { t.Fatal(err) @@ -121,7 +120,7 @@ func TestSnapshotMarkerBackfillsOnlyVerifiedManifest(t *testing.T) { b, request := markerBinding(t) source := t.TempDir() writeSourceSkill(t, source, "frozen") - input := agentcapabilities.Input{Directories: []string{source}} + input := proto.CapabilitySources{Directories: []string{source}} root, err := os.OpenRoot(b.capabilityRoot) if err != nil { t.Fatal(err) @@ -186,7 +185,7 @@ func TestSnapshotMarkerRefusesDamagedState(t *testing.T) { func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) { b, _ := markerBinding(t) identity := b.capabilityIdentity() - finalize := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}} + finalize := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &proto.CapabilitySources{}} if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err != nil { t.Fatal(err) } @@ -197,7 +196,7 @@ func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) if err := os.RemoveAll(b.capabilityRoot); err != nil { t.Fatal(err) } - skill := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + skill := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &proto.SkillMetadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} if err := b.ApplyRuntimePreparation(t.Context(), skill, []byte("x")); err == nil { t.Fatal("completed identity accepted import") } diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index afaed2199..22094814f 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -6,40 +6,37 @@ import ( "io/fs" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // This private transfer bound is distinct from the public inline-file limit. 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 == proto.SessionStateKey(session) } // WriteWorkspaceFile starts only after the caller supplies the complete bounded // body. Core must persist mutation ownership before invoking this operation. -func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (result agent.WorkspaceWriteResult, err error) { +func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (result WorkspaceWriteResult, err error) { if b == nil || b.writer == nil { - return result, agent.ErrWorkspaceWriteUnsupported + return result, ErrWorkspaceWriteUnsupported } if len(data) > WriteMaxBytes || len(path) > 4096 || path == "." || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { - return result, agent.ErrWorkspaceWriteInvalid + return result, ErrWorkspaceWriteInvalid } if ctx.Err() != nil { - return result, agent.ErrWorkspaceWriteUnavailable + return result, ErrWorkspaceWriteUnavailable } w := b.writer if !w.mu.TryLock() { - return result, agent.ErrWorkspaceWriteBusy + return result, ErrWorkspaceWriteBusy } defer w.mu.Unlock() if w.uncertain { - return result, agent.ErrWorkspaceWriteUncertain + return result, ErrWorkspaceWriteUncertain } - defer func() { w.uncertain = errors.Is(err, agent.ErrWorkspaceWriteUncertain) }() + defer func() { w.uncertain = errors.Is(err, ErrWorkspaceWriteUncertain) }() // Once admitted, finish this synchronous mutation before returning ownership. return b.writeNativeFile(context.WithoutCancel(ctx), path, data) } diff --git a/apps/daemon/internal/localworkspace/write_test.go b/apps/daemon/internal/localworkspace/write_test.go index fe15e99f9..a7087340f 100644 --- a/apps/daemon/internal/localworkspace/write_test.go +++ b/apps/daemon/internal/localworkspace/write_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "os" "path/filepath" "testing" @@ -24,7 +23,7 @@ func TestNativeFileCreateAndNoReplace(t *testing.T) { if err != nil || got.SizeBytes != int64(size) { t.Fatal(size, got, err) } - if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, agent.ErrWorkspaceWriteUnsafe) { + if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, ErrWorkspaceWriteUnsafe) { t.Fatal("existing file replaced", err) } raw, err := os.ReadFile(filepath.Join(b.workspace, filepath.FromSlash(name))) @@ -32,7 +31,7 @@ func TestNativeFileCreateAndNoReplace(t *testing.T) { t.Fatal("file content changed", err) } } - if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, agent.ErrWorkspaceWriteDirectory) { + if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, ErrWorkspaceWriteDirectory) { t.Fatal(err) } if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { @@ -51,16 +50,16 @@ func TestNativeFileCreateAndNoReplace(t *testing.T) { func TestNativeFileAdmission(t *testing.T) { b := nativeFileBinding(t) for _, path := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { - if _, err := b.WriteWorkspaceFile(t.Context(), path, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + if _, err := b.WriteWorkspaceFile(t.Context(), path, nil); !errors.Is(err, ErrWorkspaceWriteInvalid) { t.Fatal(path, err) } } - if _, err := b.WriteWorkspaceFile(t.Context(), "large", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + if _, err := b.WriteWorkspaceFile(t.Context(), "large", make([]byte, WriteMaxBytes+1)); !errors.Is(err, ErrWorkspaceWriteInvalid) { t.Fatal(err) } ctx, cancel := context.WithCancel(t.Context()) cancel() - if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, agent.ErrWorkspaceWriteUnavailable) { + if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, ErrWorkspaceWriteUnavailable) { t.Fatal(err) } if _, err := os.Stat(filepath.Join(b.workspace, "cancelled")); !os.IsNotExist(err) { @@ -69,11 +68,11 @@ func TestNativeFileAdmission(t *testing.T) { b.writer.mu.Lock() _, err := b.WriteWorkspaceFile(t.Context(), "busy", nil) b.writer.mu.Unlock() - if !errors.Is(err, agent.ErrWorkspaceWriteBusy) { + if !errors.Is(err, ErrWorkspaceWriteBusy) { t.Fatal(err) } b.writer.uncertain = true - if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, ErrWorkspaceWriteUncertain) { t.Fatal(err) } } diff --git a/apps/daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go index d6af78594..8549d628b 100644 --- a/apps/daemon/internal/paths/paths.go +++ b/apps/daemon/internal/paths/paths.go @@ -34,8 +34,7 @@ func ValidateProfile(name string) error { return nil } -// Root returns ~/.oac. Honours OAC_RUNTIME_HOME for tests / -// sandbox environments without a writable home. +// Root returns ~/.oac, or the operator-selected OAC_RUNTIME_HOME when set. func Root() (string, error) { if override := os.Getenv("OAC_RUNTIME_HOME"); override != "" { if !filepath.IsAbs(override) || filepath.Clean(override) != override { diff --git a/apps/daemon/internal/transport/bootstrap.go b/apps/daemon/internal/transport/bootstrap.go index b684fee5d..5ca44a9ed 100644 --- a/apps/daemon/internal/transport/bootstrap.go +++ b/apps/daemon/internal/transport/bootstrap.go @@ -19,36 +19,18 @@ import ( "net/http" "net/url" "strings" - "time" -) - -// BootstrapResponse mirrors gateway/handler.go's Bootstrap response. -type BootstrapResponse struct { - DeviceID string `json:"device_id"` - WorkspaceID string `json:"workspace_id"` - WSURL string `json:"ws_url"` - HeartbeatSeconds int `json:"heartbeat_seconds"` - ProtocolVersion string `json:"protocol_version"` -} -// HeartbeatInterval defends against the server returning 0 — a zero -// heartbeat means "never ping" which guarantees a 60s timeout, so we -// fall back to 15s instead of silently breaking liveness. -func (b BootstrapResponse) HeartbeatInterval() time.Duration { - if b.HeartbeatSeconds <= 0 { - return 15 * time.Second - } - return time.Duration(b.HeartbeatSeconds) * time.Second -} + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" +) // Bootstrap calls POST /agent-daemon/bootstrap with the // device's runner_credential as a bearer token. -func Bootstrap(ctx context.Context, serverURL, deviceID, credential, daemonVersion string) (*BootstrapResponse, error) { +func Bootstrap(ctx context.Context, serverURL, deviceID, credential, daemonVersion string) (*machineapi.BootstrapResponse, error) { return BootstrapWithClient(ctx, http.DefaultClient, serverURL, deviceID, credential, daemonVersion) } // BootstrapWithClient lets a connection entry point enforce its redirect policy. -func BootstrapWithClient(ctx context.Context, client *http.Client, serverURL, deviceID, credential, daemonVersion string) (*BootstrapResponse, error) { +func BootstrapWithClient(ctx context.Context, client *http.Client, serverURL, deviceID, credential, daemonVersion string) (*machineapi.BootstrapResponse, error) { if strings.TrimSpace(serverURL) == "" { return nil, fmt.Errorf("transport.Bootstrap: serverURL required") } @@ -58,7 +40,7 @@ func BootstrapWithClient(ctx context.Context, client *http.Client, serverURL, de if strings.TrimSpace(credential) == "" { return nil, fmt.Errorf("transport.Bootstrap: credential required") } - body, err := json.Marshal(map[string]string{"device_id": deviceID}) + body, err := json.Marshal(machineapi.BootstrapRequest{DeviceID: deviceID}) if err != nil { return nil, fmt.Errorf("transport.Bootstrap: marshal body: %w", err) } @@ -86,7 +68,7 @@ func BootstrapWithClient(ctx context.Context, client *http.Client, serverURL, de if resp.StatusCode/100 != 2 { return nil, fmt.Errorf("transport.Bootstrap: server returned %s: %s", resp.Status, strings.TrimSpace(string(raw))) } - var out BootstrapResponse + var out machineapi.BootstrapResponse if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("transport.Bootstrap: decode response: %w", err) } @@ -117,7 +99,7 @@ func joinURL(base, path string) (string, error) { // is derived from serverBase by swapping http→ws / https→wss. Non- // absolute ws_url is rejected — the server-side handler is the only // component that knows the externally-reachable host. -func DeriveWSURL(boot BootstrapResponse, serverBase string) (string, error) { +func DeriveWSURL(boot machineapi.BootstrapResponse, serverBase string) (string, error) { if abs := strings.TrimSpace(boot.WSURL); abs != "" { u, err := url.Parse(abs) if err != nil { diff --git a/apps/daemon/internal/transport/bootstrap_test.go b/apps/daemon/internal/transport/bootstrap_test.go index 1b8467f4f..7585e8304 100644 --- a/apps/daemon/internal/transport/bootstrap_test.go +++ b/apps/daemon/internal/transport/bootstrap_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/internal/machineapi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" ) @@ -32,10 +34,8 @@ func TestBootstrapSendsBearerAndDeviceID(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "device_id": "rt_abc", - "workspace_id": "ws_xyz", "ws_url": "wss://example/agent-daemon/ws", "heartbeat_seconds": 15, - "protocol_version": "0.3.0", }) })) defer srv.Close() @@ -44,7 +44,7 @@ func TestBootstrapSendsBearerAndDeviceID(t *testing.T) { if err != nil { t.Fatalf("Bootstrap: %v", err) } - if resp.DeviceID != "rt_abc" || resp.WorkspaceID != "ws_xyz" { + if resp.DeviceID != "rt_abc" { t.Errorf("unexpected response: %+v", resp) } if resp.WSURL != "wss://example/agent-daemon/ws" { @@ -68,7 +68,6 @@ func TestBootstrapHeartbeatIntervalDefaultsToFifteen(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "device_id": "rt", - "workspace_id": "ws", "ws_url": "", "heartbeat_seconds": 0, // server forgot to set it }) @@ -118,7 +117,7 @@ func TestBootstrapRejectsEmptyInputs(t *testing.T) { } func TestDeriveWSURLPrefersAbsolute(t *testing.T) { - got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "wss://prod/agent-daemon/ws"}, "https://anything") + got, err := transport.DeriveWSURL(machineapi.BootstrapResponse{WSURL: "wss://prod/agent-daemon/ws"}, "https://anything") if err != nil { t.Fatalf("DeriveWSURL: %v", err) } @@ -135,7 +134,7 @@ func TestDeriveWSURLFallsBackToServerBase(t *testing.T) { {"https://core.example.com/api", "wss://core.example.com/api/agent-daemon/ws"}, } for _, tc := range cases { - got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: ""}, tc.base) + got, err := transport.DeriveWSURL(machineapi.BootstrapResponse{WSURL: ""}, tc.base) if err != nil { t.Errorf("DeriveWSURL(%q): %v", tc.base, err) continue @@ -147,14 +146,14 @@ func TestDeriveWSURLFallsBackToServerBase(t *testing.T) { } func TestDeriveWSURLRejectsRelativeWSURL(t *testing.T) { - _, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "/agent-daemon/ws"}, "https://x") + _, err := transport.DeriveWSURL(machineapi.BootstrapResponse{WSURL: "/agent-daemon/ws"}, "https://x") if err == nil { t.Fatal("DeriveWSURL accepted relative ws_url") } } func TestDeriveWSURLRejectsBadScheme(t *testing.T) { - _, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: ""}, "ftp://example") + _, err := transport.DeriveWSURL(machineapi.BootstrapResponse{WSURL: ""}, "ftp://example") if err == nil { t.Fatal("DeriveWSURL accepted ftp scheme") } diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go index 3276a9c17..a55bb47f8 100644 --- a/apps/daemon/internal/transport/ws.go +++ b/apps/daemon/internal/transport/ws.go @@ -21,7 +21,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// DialOptions is the input to Dial. HTTPHeader is optional. +// DialOptions is the input to Dial. type DialOptions struct { // WSURL is the absolute ws://... or wss://... URL. WSURL string @@ -40,9 +40,6 @@ type DialOptions struct { // HandshakeTimeout caps WS upgrade wait. Zero → 10s. HandshakeTimeout time.Duration - - // HTTPHeader is appended to the upgrade request. Useful in tests. - HTTPHeader http.Header } // Conn is the daemon-side WebSocket. One read goroutine + one write @@ -105,10 +102,7 @@ func Dial(ctx context.Context, opts DialOptions) (*Conn, error) { dialCtx, cancel := context.WithTimeout(ctx, opts.HandshakeTimeout) defer cancel() - headers := opts.HTTPHeader.Clone() - if headers == nil { - headers = make(http.Header) - } + headers := make(http.Header) headers.Set("Authorization", "Bearer "+opts.Credential) headers.Set(proto.ExecutionFlowHeader, proto.ExecutionFlowCreditV1) wsConn, resp, err := dialer.DialContext(dialCtx, dialURL, headers) @@ -250,7 +244,7 @@ func (c *Conn) Close() error { // StartHeartbeats kicks off a ticker that calls payloadFn every // interval and Sends the resulting HeartbeatPayload. Returns // immediately. Caller-controlled because the heartbeat carries fields -// (active_requests, supported_agent_kinds) only the agent layer knows. +// (supported_agent_kinds) only the agent layer knows. // Nil logger falls back to log.Bg(). func (c *Conn) StartHeartbeats(parentCtx context.Context, interval time.Duration, payloadFn func() proto.HeartbeatPayload, logger *slog.Logger) { if interval <= 0 || payloadFn == nil { diff --git a/apps/daemon/internal/transport/ws_test.go b/apps/daemon/internal/transport/ws_test.go index 12e4859dd..a81b6cccc 100644 --- a/apps/daemon/internal/transport/ws_test.go +++ b/apps/daemon/internal/transport/ws_test.go @@ -96,10 +96,8 @@ func TestDialPassesAuthOnlyInHeader(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(gw.handler)) defer srv.Close() - headers := http.Header{"Authorization": {"Bearer obsolete"}, "X-Test": {"preserved"}} conn, err := transport.Dial(context.Background(), transport.DialOptions{ WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", - HTTPHeader: headers, DeviceID: "rt_abc", Credential: "shh-secret", DaemonVersion: "0.1.0", @@ -122,9 +120,6 @@ func TestDialPassesAuthOnlyInHeader(t *testing.T) { if auth != "Bearer shh-secret" { t.Error("missing credential authorization header") } - if headers.Get("Authorization") != "Bearer obsolete" { - t.Error("Dial mutated caller headers") - } if !strings.Contains(dialURL, "version=0.1.0") { t.Errorf("dial URL %q missing version", dialURL) } @@ -283,7 +278,7 @@ func TestStartHeartbeatsTicks(t *testing.T) { var calls atomic.Int32 conn.StartHeartbeats(context.Background(), 30*time.Millisecond, func() proto.HeartbeatPayload { calls.Add(1) - return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), DaemonVersion: "0.0.0-dev"} + return proto.HeartbeatPayload{} }, nil) deadline := time.Now().Add(time.Second) @@ -325,7 +320,7 @@ func TestStartHeartbeatsSendsImmediately(t *testing.T) { defer cancel() conn.StartHeartbeats(hbCtx, time.Hour, func() proto.HeartbeatPayload { calls.Add(1) - return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), DaemonVersion: "0.0.0-dev"} + return proto.HeartbeatPayload{} }, nil) deadline := time.Now().Add(2 * time.Second) diff --git a/apps/daemon/internal/wireconformance/wire_test.go b/apps/daemon/internal/wireconformance/wire_test.go index e846b815f..937f2db6d 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -363,6 +363,10 @@ func (turn *controlledTurn) AwaitSettlement(ctx context.Context) (agent.TurnSett } // These fixtures exercise settlement only; active input is deliberately rejected. +func (*controlledTurn) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + return agent.ErrUnsupportedOperation +} + func (*controlledTurn) SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error { return agent.ErrSteeringRejected } diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 5f25d52af..59bc3e2ea 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -146,6 +146,11 @@ func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload return nil } +// The fixture declares no function tools. +func (s *session) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + return fmt.Errorf("%w: fixture has no function tools", agent.ErrUnsupportedOperation) +} + func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { select { case <-s.settled: diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 4ce53087f..d35739d74 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -227,7 +227,7 @@ The data contract is part of the look. Core reports only what it observes, so th - One list grammar on every resource page: project filter, search, count, name with compact ID, creator, row actions. - Tabular numerals everywhere a number can line up. - Status is always a dot plus a plain-language label. -- Task instructions and empty-state next steps stay visible. Supplementary definitions belong in help tips. Errors, warnings and safety notices stay visible. +- Task instructions and empty-state next steps stay visible. Errors, warnings and safety notices stay visible. ## Colors @@ -368,7 +368,7 @@ Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header - **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 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. +- **Use Docker?**: choosing a backend that declares cautions in sandbox setup, Docker today, titles the dialog "Use {backend}?" and lists each caution as a plain list item: for Docker, that containers share the host kernel and that the node's account joins the docker group, which is root-equivalent on the host. The footer holds Use Docker (outline) and Back (primary), which takes focus; Back, closing or Escape leaves the choice unmade. - **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. @@ -407,7 +407,7 @@ Signing in and the console tour share one frame: a dark stage on the left (alway The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open Settings; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open Settings brings the Models and credentials tab to the top of the page body and focuses the default Harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review states the reason and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend, or for E2B the Connect E2B page (service, API key, template build and endpoint), then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, each declared connection value (an empty optional one reads Provider default) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review states the reason and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away on the review page of a backend that declares a default size, hold the exact CPU, memory and disk values; E2B has none, so the review offers no link. A change keeps the saved size while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or the E2B connection with the provider fixed. With the saved key kept, the template build stays an editable text field of the saved value; entering a key loads the build list again. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Sandbox lifecycle @@ -419,14 +419,14 @@ A single rollout row opens a details dialog for Core's target generation, previo A generation-only update within the same installation/backend lifecycle retains compatible previous node/allocation evidence while refreshing. Failed or pending reads visibly qualify those observations; never replace them with fabricated zeros. Reset, backend and installation lifecycle changes discard incompatible data. The shared deployment query and write ownership below govern navigation, late reads, explicit retries and login isolation. ### Sandbox reset -Sandbox configuration offers an explicit reset through its panels and confirmation dialogs. The confirmation keeps to one concise consequence paragraph, two mode choices, the auto deadline and footer actions. Put cleanup sequencing and preservation details in help tips. Auto clear is selected first, with a one-hour deadline editable from 5 minutes to 24 hours; Force clear and escalation require destructive confirmation. State directly that hosted work is archived, remaining active work may be cancelled, archived Sessions cannot resume and unpersisted workspace contents may be lost. Details about preserved histories, Files/Artifacts and unaffected self-hosted execution live behind the reset impact help tip. Cancelling an active reset stops further clearing but cannot undo completed archives. +Sandbox configuration offers an explicit reset ([semantics](../../contracts/agents-api/sandbox-deployment.md#reset)) through its panels and confirmation dialogs. The confirmation keeps to one concise consequence paragraph, two mode choices, the auto deadline and footer actions. Put cleanup sequencing and preservation details in help tips. Auto clear is selected first, with a one-hour deadline editable from 5 minutes to 24 hours; Force clear and escalation require destructive confirmation. State directly that hosted work is archived, remaining active work may be cancelled, archived Sessions cannot resume and unpersisted workspace contents may be lost. Further consequences live behind the reset impact help tip. A persistent progress panel uses Core's busy, idle and cleanup counts, deadline and named offline-node blockers. Bring blocked nodes online for confirmed cleanup; never offer a browser-side force-release shortcut. Poll the deployment every five seconds only while its reset is non-null. A passed deadline does not establish force or completion; only a Core response does. Completion opens the setup flow, with a new explicit save using the generation read from Core, including zero on a fresh install. Reset and online configuration rollout have independent authoritative progress; neither automatically replays configuration. Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state. ### Settings page -Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes and metrics open the owning tab, and the Sandbox and scheduling header links back to Nodes. Forms save independently through their resource APIs, never through one cross-resource Save all action. +Settings is the single platform configuration entry, with one tab per [group](../../docs/configuration.md#runtime-settings-web). Each tab has a stable address; links from Nodes and metrics open the owning tab, and the Sandbox and scheduling header links back to Nodes. Forms save independently through their resource APIs, never through one cross-resource Save all action. The hierarchy is page, tab, section, row. The page header loses its rule to an underline tab row: 40px text tabs in Graphite 13px/500, the current one Ink with a 2px Ink underline. Below 640px the tabs wrap onto a second row rather than scroll out of sight. The body is a left-aligned column at most 880px wide with 32px between sections. A section is the shared Section heading (14px/600, help tip, section-wide outline actions on the right) over one card of label/value rows (`settings.css`): a 200px Graphite label column with its help tip and an Ink value column, 8px by 16px padding, 32px minimum line height, Faint Rules between rows; below 640px the label sits above the value. An action that changes one value sits at the end of its row (Edit tenant capacity; Save beside the idle-seconds field, whose error appears beneath it). Read-only status is a row too (Configuration rollout, Snapshot retention); tables sit in one framed card. Settings never repeats the KPI strips of Nodes or the metrics pages. The irreversible operation closes the page in its own section: a red-ringed card with one Ink line stating the consequence and a red-text outline button. @@ -434,12 +434,12 @@ 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 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. +Models and credentials stacks one default-model card per Harness. Its header carries the Harness name, its [readiness](../../docs/configuration.md#runtime-settings-web) (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. +Service and deployment shows installation addresses, an Installation details disclosure, closed by default, with the installation ID and source commit, 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. ### One place for each task -Each configuration has exactly one editor in Settings; other pages link to it. Resource operations such as Project keys, Session executor credentials and node removal stay on their resource pages. Keep the configuration page flat: resource editors are dialogs and rollout is one status row with a details action. Explanations belong in question-mark help tips, not small-print helper paragraphs. Errors, uncertain outcomes and current usage exceeding a new limit remain visible without duplicating the workflow. +Each configuration has exactly one editor in Settings; other pages link to it. Resource operations such as Project keys, Session executor credentials and node removal stay on their resource pages. Keep the configuration page flat: resource editors are dialogs and rollout is one status row with a details action. Explanations go in help tips, not small-print helper paragraphs. Errors, uncertain outcomes and current usage exceeding a new limit remain visible without duplicating the workflow. ### Diagnostic observations Failure reasons belong beside the failed Session or Turn status. On the Session page the Status fact stays short, and the reason and its next step read as one block under the facts, at reading width. Their first read uses a skeleton; an unavailable reason names that state and puts the read retry beside its help tip. Technical classifications are translated through one catalogue, not shown as raw error codes. Trace Timing labels Core receipt times separately from tool-reported duration; explanations of batched delivery, clock differences and historical gaps live in help tips. @@ -454,7 +454,7 @@ Motion reports state and never makes anyone wait: the navigation chip and segmen ## Do's and Don'ts ### Do: -- **Do** keep next steps visible and put supplementary definitions behind a circled "?" help tip; report errors in a dialog or a toast; keep warnings and safety notices (deletion consequences, a key shown once) visible. +- **Do** keep next steps visible; report errors in a dialog or a toast; keep warnings and safety notices (deletion consequences, a key shown once) visible. - **Do** start every project-scoped toolbar with the project filter, then search, with the count on the right. - **Do** end every resource table with the Creator column and then the row actions. - **Do** confirm every deletion in ConfirmDialog. @@ -482,6 +482,6 @@ Motion reports state and never makes anyone wait: the navigation chip and segmen Use Lucide throughout. Navigation icons are 18px with a 1.75px stroke; shared tool icons use the same stroke at their existing compact sizes. Default navigation icons use secondary ink, selected icons use primary ink. The selected row has a neutral background and a narrow indigo marker. -Getting started emphasizes the first confirmed incomplete step with the primary button while leaving every step independently available. Only authoritative, successful zero-Session reads replace the activity chart with first-use guidance. Missing and failed reads retain their existing uncertainty and recovery controls. +Getting started emphasizes the first confirmed incomplete step with the primary button while leaving every step independently available. Only authoritative, successful zero-Session reads replace the activity chart with first-use guidance. Missing and failed reads retain their uncertainty and recovery controls. -Configuration controls show concise labels, values and actions. Put necessary explanatory policy text behind a `HelpTip` question mark; do not add persistent small-print helper text or note paragraphs. Validation and request failures remain visible beside the affected action or field. +Configuration controls show concise labels, values and actions. Validation and request failures remain visible beside the affected action or field. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index e7320e85a..7c79d41b1 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -31,22 +31,22 @@ 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 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. +- **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. 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 its [readiness](../../docs/configuration.md#runtime-settings-web), with Set as the primary action when the model provider is not set and a link to the startup settings when it is disabled. 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. +- **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. Its semantics are defined in [Reset](../../contracts/agents-api/sandbox-deployment.md#reset); Web shows only Core's progress and completion, and a new configuration is a separate deliberate save. - **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. -- **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default Harness (on any enabled Harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. +- **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 or changing, and the size of each) 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. 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. +- **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment), a default model provider on the default Harness (on any enabled Harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. - Terminology: API terms stay in English in the Chinese UI and docs (Agent, Session, Turn, Item, Runtime, Environment, Skill, Vault, Credential, Harness); providers are 提供商 (沙箱提供商, 模型提供商). The sign-in credential is the Core key ("Core 密钥"); keys issued in a project for applications are project API keys ("项目 API 密钥"). Empty lists read "No … yet" ("还没有…"). Sandbox provider readiness is "Provider not ready / 沙箱提供商未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms. ## Capabilities and Constraints - **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. The list shows active projects by default; the Archived filter, with its count, shows the rest. Archiving a project revokes every key and keeps its assets viewable and deletable; it can't be undone, so its confirmation says so, counts the active keys it revokes and, when there are any, asks for the project's name. Key plaintext is shown once, at issuance, and never stored by the console. Beside it, and without the key on an active project's page, the console tells developers to set `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python samples that list Agents and create a Session. -- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures. Each fact has one page: Nodes owns the node list, per-node capacity and host figures; Overview summarizes capacity and links to Nodes; Sandbox metrics owns Runtime time series and activity; Core metrics owns the Core process. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. -- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the existing storage API. It shows the adapter and tenant capacity; the configuration ID and adapter parameters stay under Configuration details. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save. +- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures. Each fact has one page: Nodes owns the node list, per-node capacity and host figures; Overview summarizes capacity and links to Nodes; Sandbox metrics owns Runtime time series and activity; Core metrics owns the Core process. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. +- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the storage API. It shows the adapter and tenant capacity; the configuration ID and adapter parameters stay under Configuration details. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save. - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **Secrets stay write-only.** Credential tokens, Template environment variables and setup commands are never returned, to the administrator included. An Agent's saved model provider shows its protocol, base URL, limits and whether a key is configured, never the key. @@ -62,7 +62,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Typed errors.** Every failure the console shows, read or write, follows one rule. Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; a failure without a code names its HTTP status, and one that never reached Core says so. Uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** A read is tried again once after a 5xx answer or a failed connection, never after a 4xx answer. Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; a first read that fails never mentions earlier data; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. - **Local-only address.** Overview, Nodes and Settings warn when Core reports `local_only` and lead to Settings → Service and deployment to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. -- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. +- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. - Runtime CPU and memory exist only for Core-managed hosted sandboxes. - Preserve workflow safety: confirmed deletion, no automatic retry of uncertain writes, no secrets in browser storage. @@ -87,4 +87,4 @@ The console runs beside the administrator's own Core, with execution, files and ## Accessibility & Inclusion -Keyboard navigation with visible focus, reduced-motion support, and bilingual Chinese/English copy through the existing i18n modules. +Keyboard navigation with visible focus, reduced-motion support, and bilingual Chinese/English copy through the i18n modules. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index fbe39052b..14381899f 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -44,9 +44,10 @@ export async function openConsole(page: Page, request: APIRequestContext, hash = /** Selects the ready build returned by the E2B discovery fixture. */ export async function selectFixtureE2BBuild(page: Page) { - await page.getByLabel("E2B API key").fill("fixture-private-key"); - await page.getByLabel("Template", { exact: true }).selectOption("template"); - await page.getByLabel("Template build").selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await page.getByLabel("API key").fill("fixture-private-key"); + const template = page.getByLabel("Template build"); + await template.first().selectOption("template"); + await template.nth(1).selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe"); } /** Makes the next matching write fail once with the given status. */ diff --git a/apps/web/e2e/data/admin.mjs b/apps/web/e2e/data/admin.mjs index 058d35875..3d4265b08 100644 --- a/apps/web/e2e/data/admin.mjs +++ b/apps/web/e2e/data/admin.mjs @@ -44,7 +44,7 @@ export function buildAdmin(now, base, resources) { vaults: resources.vaults.filter(of("vault", projectId)), }); - const keyRef = (project, k) => ({ id: k.id, name: k.name, prefix: k.prefix, kind: "issued", revoked_at: k.revoked_at ? iso(k.revoked_at) : null }); + const keyRef = (k) => ({ id: k.id, name: k.name, prefix: k.prefix, kind: "issued", revoked_at: k.revoked_at ? iso(k.revoked_at) : null }); // Deterministic creator per resource: rotate through the project's keys; one in seven is unknown. const creators = new Map(); let turn = 0; @@ -52,7 +52,7 @@ export function buildAdmin(now, base, resources) { const cacheKey = `${type}:${id}`; if (!creators.has(cacheKey)) { turn += 1; - creators.set(cacheKey, turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); + creators.set(cacheKey, turn % 7 === 0 ? null : keyRef(project.keys[turn % project.keys.length])); } return creators.get(cacheKey); }; @@ -61,7 +61,7 @@ export function buildAdmin(now, base, resources) { const own = collections(project.id); const list = []; const push = (at, action, type, id, parent = "", creatorType = type) => { - const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project, project.keys[list.length % project.keys.length]); + const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project.keys[list.length % project.keys.length]); list.push({ id: `op_${project.id.slice(5)}_${list.length}`, created_at: iso(at), api_key: creator, action, resource_type: type, resource_id: id, parent_id: parent, request_id: `req_${list.length}`, trace_id: `${list.length}`.padStart(32, "0") }); }; for (const agent of own.agents) { push(agent.created_at, "create", "agent", agent.id); if (agent.updated_at > agent.created_at) push(agent.updated_at, "update", "agent", agent.id); } diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index 535af7a62..adddd9dea 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -15,7 +15,6 @@ function daemonUrl(publicUrl) { export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "https://core.example.com") { seed = 42; - const models = ["gpt-5.1-codex", "claude-sonnet-5", "MiniMax-M2", "gpt-5.1-mini"]; const agents = agentDefinitions.map((definition, index) => ({ id: `agent_${index + 1}${uuid().slice(0, 6)}`, object: "agent", model: definition.model, name: definition.name, instructions: definition.instructions, metadata: definition.metadata, multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: definition.reasoning, service_tier: "auto", @@ -101,11 +100,11 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http }); const observations = sessions.map((session) => { const base = { id: session.id, object: "agent.runtime_observation", session_id: session.id, resolved_at: now }; - if (session.environment.type === "none") return { ...base, environment_id: null, mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null }; - if (session.environment.type === "self_hosted") return { ...base, environment_id: session.environment.id, mode: "self_hosted", provider_type: null, instance: { kind: "self_hosted_connection", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null }; + if (session.environment.type === "none") return { ...base, environment_id: null, mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, memory: null }; + if (session.environment.type === "self_hosted") return { ...base, environment_id: session.environment.id, mode: "self_hosted", provider_type: null, instance: { kind: "self_hosted_connection", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, memory: null }; const allocation = allocations.find((entry) => entry.session_id === session.id); const sleeping = allocation.compute_phase === "suspended"; - return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, cpu: sleeping ? null : { usage_seconds_total: 600 + Math.floor(rand() * 4000), capacity_cores: 2, usage_cores: Number((rand() * 1.6).toFixed(2)), utilization_ratio: null }, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; + return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; }); // Added after everything else is generated, so the seeded data above does not change. agents.push(...providerAgentDefinitions.map((definition, index) => ({ diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 1f2513802..a6c92d720 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -82,18 +82,18 @@ function nodeRollout(previous = 0) { } function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { - return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, suspension: null }; } 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 } }, 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, running: 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 } }; // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, running: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; } function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", installers = true, artifacts = "docker,microsandbox") { @@ -342,8 +342,9 @@ async function sandboxRoute(request, response, path, url) { ["https://api.e2b.app", "e2b.app"], ].some(([apiURL, domain]) => input.configuration?.api_url === apiURL && input.configuration?.domain === domain); if (input.credential?.api_key !== "fixture-private-key" || !knownEndpoint) return error(response, 400, "Invalid E2B connection."); - if (!input.query?.template) return send(response, 200, { templates: [{ id: "template", names: ["fixture-runtime"] }] }); - return send(response, 200, { builds: [{ id: "94be54a1-138c-4f30-bc87-b13686272dbe", cpus: 2, memory_mib: 2048 }] }); + if (input.query?.field !== "template") return error(response, 400, "Invalid discovery field."); + if (input.query.path.length === 0) return send(response, 200, { options: [{ value: "template", label: "fixture-runtime · template", complete: false }] }); + return send(response, 200, { options: [{ value: "template:94be54a1-138c-4f30-bc87-b13686272dbe", label: "94be54a1-138c-4f30-bc87-b13686272dbe", resources: { cpus: 2, memory_mib: 2048 }, complete: true }] }); } // Retired even for authenticated callers; never reinterpret maintenance as reset. if (path === "/deployment/maintenance") return error(response, 404, "Not found."); @@ -418,7 +419,7 @@ async function sandboxRoute(request, response, path, url) { if (!initialize && !e2b) { for (const node of state.nodes) node.rollout = { state: node.online ? "preparing" : "unknown", ready_generation: node.rollout.ready_generation }; } - const held = initialize ? { allocations: 0, pending: 0 } : previous.resources; + const held = initialize ? { allocations: 0, running: 0, pending: 0 } : previous.resources; state.deployment = { ...configuredDeployment(), provider: input.provider, mode: e2b ? "direct" : "nodes", generation: previous.generation + 1, owner_epoch: previous.owner_epoch, @@ -533,7 +534,7 @@ const tokenLimit = (value) => value === undefined || (Number.isInteger(value) && const jsonKind = (value) => Array.isArray(value) ? "an array" : typeof value === "string" ? "a string" : typeof value === "boolean" ? "a boolean" : Number.isInteger(value) ? "an integer" : "a number"; /** The first rule a provider body breaks, in Core's order and words; null when valid. */ -function providerProblem(harness, input) { +function providerProblem(input) { if (Object.keys(input).some((key) => !PROVIDER_FIELDS.has(key)) || ["protocol", "base_url", "api_key"].some((key) => typeof input[key] !== "string") || !tokenLimit(input.context_window) || !tokenLimit(input.max_output_tokens)) return PROVIDER_SHAPE; if (!httpsBase(input.base_url)) return "model provider requires an HTTPS base_url without credentials, query or fragment"; @@ -581,7 +582,7 @@ async function harnessRoute(request, response, path) { if (typeof input.model !== "string" || !input.model.trim()) return error(response, 400, "A model ID is required.", "model_configuration_model_invalid"); if (input.harness_config !== undefined && (!input.harness_config || typeof input.harness_config !== "object" || Array.isArray(input.harness_config))) return error(response, 400, "Harness configuration must be an object.", "harness_config_invalid"); if (!support(harness).protocols.includes(input.model_provider?.protocol)) return error(response, 400, "This harness does not support this protocol.", "model_provider_protocol_unsupported"); - const problem = providerProblem(harness, input.model_provider ?? {}); + const problem = providerProblem(input.model_provider ?? {}); if (problem) return error(response, 400, problem, "invalid_request_error"); entry.provider = { object: "core.model_configuration", harness, model: input.model, harness_config: input.harness_config ?? {}, diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 8ca0e5ca8..b18ec423d 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -250,13 +250,11 @@ test("shows a node's host on Nodes and opens a sandbox in a dialog from Sandbox test("shows E2B's cloud instead of machines", async ({ page, request }) => { await openConsole(page, request, "sandbox-metrics", { sandbox: "e2b" }); - await expect(page.getByRole("heading", { name: "E2B cloud" })).toBeVisible(); - // Each sandbox takes the template build's size, disk included. - await expect(page.getByText("10 GiB disk")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Provider cloud" })).toBeVisible(); await expect(page.getByRole("button", { name: "Add node" })).toHaveCount(0); await expect(page.getByRole("columnheader", { name: "Node", exact: true })).toHaveCount(0); await page.getByRole("navigation").getByRole("button", { name: "Nodes", exact: true }).click(); await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeVisible(); - await expect(page.getByText("E2B runs sandboxes in its cloud. There are no nodes to manage.")).toBeVisible(); + await expect(page.getByText("The provider runs sandboxes in its cloud. There are no nodes to manage.")).toBeVisible(); }); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 5fb60fca2..bb0889aa6 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -229,13 +229,13 @@ test("preselects microsandbox and asks once before switching to Docker", async ( await page.getByRole("button", { name: "Own machines" }).click(); const microsandbox = page.getByRole("button", { name: "microsandbox Recommended" }); const docker = page.getByRole("button", { name: "Docker", exact: true }); - const confirm = page.getByRole("dialog", { name: "Use Docker instead of microsandbox?" }); + const confirm = page.getByRole("dialog", { name: "Use Docker?" }); const sizeStep = page.getByRole("heading", { name: "How big is each sandbox?" }); await expect(microsandbox).toHaveAttribute("aria-pressed", "true"); // Keeping microsandbox is the default action; it leaves microsandbox selected. await docker.click(); - const keep = confirm.getByRole("button", { name: "Keep microsandbox" }); + const keep = confirm.getByRole("button", { name: "Back" }); await expect(keep).toBeFocused(); await keep.click(); await expect(confirm).toBeHidden(); @@ -258,8 +258,8 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, if (sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/deployment")) submitted = sent.postDataJSON() as Record; }); await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); - await page.getByRole("button", { name: "E2B cloud" }).click(); - await expect(page.getByLabel("E2B provider")).toHaveValue("sandbase"); + await page.getByRole("button", { name: "E2B", exact: true }).click(); + await expect(page.getByLabel("Service")).toHaveValue("0"); await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://sandbox.sandbase.ai"); await expect(page.getByLabel("Sandbox data-plane domain")).toHaveValue("sandbox.sandbase.ai"); const keyConsole = page.getByRole("link", { name: "Console → API keys" }); @@ -283,21 +283,20 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, test("uses the official E2B preset and clears a selected build when the key changes", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox", { sandbox: "none" }); - await page.getByRole("button", { name: "E2B cloud" }).click(); - await page.getByLabel("E2B provider").selectOption("official"); + await page.getByRole("button", { name: "E2B", exact: true }).click(); + await page.getByLabel("Service").selectOption("1"); await expect(page.getByLabel("Sandbox API URL")).toHaveValue("https://api.e2b.app"); await expect(page.getByLabel("Sandbox data-plane domain")).toHaveValue("e2b.app"); const keyConsole = page.getByRole("link", { name: "Console → API keys" }); await expect(keyConsole).toHaveAttribute("href", "https://e2b.dev/dashboard?tab=api-keys"); await page.getByLabel("Sandbox API URL").fill("https://custom.e2b.app"); await expect(keyConsole).toHaveCount(0); - await page.getByLabel("E2B provider").selectOption("custom"); + await page.getByLabel("Service").selectOption("-1"); await expect(keyConsole).toHaveCount(0); - await page.getByLabel("E2B provider").selectOption("official"); + await page.getByLabel("Service").selectOption("1"); await selectFixtureE2BBuild(page); await expect(page.getByRole("button", { name: "Next" })).toBeEnabled(); - await page.getByLabel("E2B API key").fill("changed-fixture-key"); - await expect(page.getByLabel("Template", { exact: true })).toHaveValue(""); + await page.getByLabel("API key").fill("changed-fixture-key"); await expect(page.getByLabel("Template build")).toHaveValue(""); await expect(page.getByRole("button", { name: "Next" })).toBeDisabled(); }); @@ -308,15 +307,15 @@ test("shows the retained E2B build while a replacement key is checked", async ({ const edit = page.getByRole("dialog", { name: "Change resources" }); const saved = "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"; await expect(edit.getByLabel("Template build")).toHaveValue(saved); - await edit.getByLabel("E2B API key").fill("replacement-fixture-key"); - await expect(edit.getByLabel("Template build")).toHaveValue(saved); + await edit.getByLabel("API key").fill("replacement-fixture-key"); + await expect(edit.getByLabel("Template build").first()).toContainText(saved); await expect(edit.getByRole("button", { name: "Next" })).toBeEnabled(); }); 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), + owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), suspension: null }; let submitted: Record | null = null; await page.route("**/core/v1/sandbox/deployment", async (route) => { @@ -330,7 +329,7 @@ test("edits only the saved backend, preserving a custom size", async ({ page, re await openConsole(page, request, "settings?id=sandbox"); await page.getByRole("button", { name: "Change resources" }).click(); await expect(page.getByRole("button", { name: "Own machines" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "E2B cloud" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "E2B", exact: true })).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 }); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index d5d96e1cf..b7a7ddd2a 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -24,14 +24,14 @@ test("sets up sandboxes with Core's address read-only, never sending it", async test("explains an E2B rejection in the wizard, with a link to domain setup", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox", { sandbox: "none", installation: "local" }); - await page.getByRole("button", { name: "E2B cloud" }).click(); + await page.getByRole("button", { name: "E2B", exact: true }).click(); await selectFixtureE2BBuild(page); await page.getByRole("button", { name: "Next" }).click(); const address = page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" }); - await expect(address).toContainText("Set a public address before connecting remote nodes"); + await expect(address).toContainText("Set a public address before connecting; E2B sandboxes need an HTTPS one."); await page.getByRole("button", { name: "Save configuration" }).click(); const rejection = page.locator(".wizard-rejection"); - await expect(rejection).toContainText("E2B sandboxes need a public HTTPS address."); + await expect(rejection).toContainText("This sandbox provider needs a public HTTPS address."); await expect(rejection.getByRole("button", { name: "Managed in Settings" })).toBeVisible(); // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. await expect(page.getByRole("dialog")).toHaveCount(0); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index 7f19b121a..a94ac1432 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -33,8 +33,8 @@ async function saveConfiguration(page: Page) { } async function editE2B(page: Page, key?: string) { await page.getByRole("button", { name: "Change resources", exact: true }).click(); - await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue(""); - if (key !== undefined) await page.getByLabel("E2B API key", { exact: true }).fill(key); + await expect(page.getByLabel("API key", { exact: true })).toHaveValue(""); + if (key !== undefined) await page.getByLabel("API key", { exact: true }).fill(key); await page.getByRole("button", { name: "Next", exact: true }).click(); } @@ -58,7 +58,7 @@ test("online configuration retains existing resources and stops rapid polling wh await expect(rollout(page)).toBeVisible(); const before = await inventory(page); expect(before.allocations.length).toBeGreaterThan(0); - await setDeployment(request, { resources: { allocations: before.allocations.length, pending: 0 } }); + await setDeployment(request, { resources: { allocations: before.allocations.length, running: before.allocations.length, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await page.getByRole("button", { name: "Change resources", exact: true }).click(); await page.getByRole("button", { name: /^Large/ }).click(); @@ -212,7 +212,7 @@ test("a different E2B team leaves the committed configuration intact and require await editE2B(page, "fixture-other-team-key"); const rejected = await saveConfiguration(page); expect(rejected.response.status()).toBe(409); - await expect(page.locator(".wizard-rejection")).toContainText("Reset before changing teams."); + await expect(page.locator(".wizard-rejection")).toContainText("Reset before changing accounts."); await expect(page.locator(".wizard-rejection")).toContainText("the saved configuration is unchanged"); expect(await deploymentRead(page)).toEqual(before); await expect(page.getByRole("button", { name: "Save configuration", exact: true })).toBeDisabled(); diff --git a/apps/web/e2e/sandbox-reset.spec.ts b/apps/web/e2e/sandbox-reset.spec.ts index cb6c1e802..ca6cd7086 100644 --- a/apps/web/e2e/sandbox-reset.spec.ts +++ b/apps/web/e2e/sandbox-reset.spec.ts @@ -41,7 +41,7 @@ test("auto reset requires a bounded deadline, then preserves Core progress past await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); await openConsole(page, request, "settings?id=sandbox"); - await setDeployment(request, { resources: { allocations: 3, pending: 1 } }); + await setDeployment(request, { resources: { allocations: 3, running: 3, pending: 1 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); await expect(dialog).toBeVisible(); @@ -71,7 +71,7 @@ test("auto reset requires a bounded deadline, then preserves Core progress past expect((await submitted).postDataJSON()).toEqual({ expected_generation: 1, clear: "auto", deadline_seconds: 300 }); await expect(progress(page)).toBeVisible(); // These timestamps are in the past: the browser still cannot invent force or completion. - await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); + await setDeployment(request, { resources: { allocations: 3, running: 3, pending: 1 }, reset: observedReset() }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await expect(progress(page)).toContainText("offline-build-worker"); await expect(progress(page)).toContainText("Busy work can finish until the deadline."); @@ -83,7 +83,7 @@ test("auto reset requires a bounded deadline, then preserves Core progress past test("escalation and cancellation require explicit confirmation and never undo previous clearing", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); - await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); + await setDeployment(request, { resources: { allocations: 3, running: 3, pending: 1 }, reset: observedReset() }); await page.reload(); await progress(page).getByRole("button", { name: "Force reset now" }).click(); let confirm = page.getByRole("dialog", { name: "Force reset now?" }); @@ -110,7 +110,7 @@ test("escalation and cancellation require explicit confirmation and never undo p test("force reset requires confirmation and reconfiguration uses the completed generation once", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); - await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); + await setDeployment(request, { resources: { allocations: 1, running: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); await dialog.getByLabel("Force — cancel remaining work now", { exact: true }).check(); @@ -134,7 +134,7 @@ test("force reset requires confirmation and reconfiguration uses the completed g test("an applied reset with a lost response stays blocked through failed reads and is never replayed", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); - await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); + await setDeployment(request, { resources: { allocations: 1, running: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); let failReads = false; @@ -165,7 +165,7 @@ test("an applied reset with a lost response stays blocked through failed reads a test("a stale reset is refused until fresh state is reviewed, with no automatic resubmission", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); - await setDeployment(request, { generation: 2, resources: { allocations: 3, pending: 1 } }); + await setDeployment(request, { generation: 2, resources: { allocations: 3, running: 3, pending: 1 } }); const dialog = await openReset(page); const staleSubmission = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith(resetPath)); const freshRead = page.waitForResponse(async (received) => received.request().method() === "GET" @@ -195,7 +195,7 @@ test("reset progress survives failed node and deployment reads with visible qual await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "dark" }); await openConsole(page, request, "settings?id=sandbox"); - await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); + await setDeployment(request, { resources: { allocations: 3, running: 3, pending: 1 }, reset: observedReset() }); await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill(unavailable)); await page.reload(); await expect(progress(page)).toContainText("offline-build-worker"); @@ -219,7 +219,7 @@ test("reset progress survives failed node and deployment reads with visible qual test("a pending reset survives navigation and a lost response cannot reopen stale write controls", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); - await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); + await setDeployment(request, { resources: { allocations: 1, running: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); let accepted!: () => void; @@ -281,7 +281,7 @@ test("Nodes and configuration adopt reset and completion learned on Overview bef await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); const cachedAt = Date.now(); await page.getByRole("button", { name: "Overview", exact: true }).click(); - await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); + await setDeployment(request, { resources: { allocations: 3, running: 3, pending: 1 }, reset: observedReset() }); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.getByRole("heading", { name: "Sandbox reset in progress" })).toBeVisible(); await expectEnrollmentBlocked(page); @@ -292,7 +292,7 @@ test("Nodes and configuration adopt reset and completion learned on Overview bef // No refresh click: shared active reset starts bounded read-only polling on configuration. const forced = observedReset("force"); forced.remaining = { busy: 0, idle: 0, cleanup: 1, on_offline_nodes: 1, offline_nodes: [{ node_id: "offline-owned", name: "offline-build-worker", resources: 1 }] }; - await setDeployment(request, { resources: { allocations: 1, pending: 0 }, reset: forced }); + await setDeployment(request, { resources: { allocations: 1, running: 1, pending: 0 }, reset: forced }); await expect(progress(page)).toContainText("Remaining hosted Sessions are being archived", { timeout: 12_000 }); await expect(progress(page).getByRole("button", { name: "Force reset now" })).toHaveCount(0); @@ -314,7 +314,7 @@ test("Nodes and configuration adopt reset and completion learned on Overview bef test("signing out clears pending reset ownership and a late response cannot overwrite the new login", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); - await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); + await setDeployment(request, { resources: { allocations: 1, running: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); let accepted!: () => void; diff --git a/apps/web/e2e/sandbox-rollout-details.spec.ts b/apps/web/e2e/sandbox-rollout-details.spec.ts index 4695ac5ee..43d5d2ec7 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, unknown: 4 } } } }); + await route.fulfill({ response, json: { ...deployment, generation: 4, resources: { allocations: 8, running: 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 }); diff --git a/apps/web/e2e/unified-settings.spec.ts b/apps/web/e2e/unified-settings.spec.ts index 8b4e17c33..549e8495e 100644 --- a/apps/web/e2e/unified-settings.spec.ts +++ b/apps/web/e2e/unified-settings.spec.ts @@ -118,7 +118,7 @@ test("settings content width does not constrain the reset dialog backdrop", asyn test("reset progress actions stay inside settings on narrow screens", async ({ page, request }) => { await page.setViewportSize({ width: 375, height: 844 }); await openConsole(page, request, "settings?id=sandbox"); - await setDeployment(request, { resources: { allocations: 1, pending: 0 }, reset: { clear: "auto", requested_at: "2026-10-13T08:00:00Z", deadline_at: "2026-10-13T09:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }); + await setDeployment(request, { resources: { allocations: 1, running: 1, pending: 0 }, reset: { clear: "auto", requested_at: "2026-10-13T08:00:00Z", deadline_at: "2026-10-13T09:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); const region = page.getByRole("region", { name: "Reset in progress" }); await expect(region).toBeVisible(); diff --git a/apps/web/package.json b/apps/web/package.json index 10e47110a..a2257a27c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,6 @@ "@number-flow/react": "^0.6.2", "@oac/agents-client": "workspace:*", "@tanstack/react-query": "^5.103.2", - "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "i18next": "^26.4.2", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index b2ed99ca6..2d3af107c 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from "@playwright/test"; const fixturePort = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); const webPort = Number(process.env.AGENTS_WEB_PORT ?? 4174); -const reuseExistingServer = process.env.AGENTS_REUSE_E2E_SERVERS === "1"; export default defineConfig({ testDir: "./e2e", @@ -27,7 +26,6 @@ export default defineConfig({ { command: "node e2e/fixture-console.mjs", url: `http://127.0.0.1:${fixturePort}/__fixture/health`, - reuseExistingServer, timeout: 15_000, stdout: "pipe", stderr: "pipe", @@ -35,7 +33,6 @@ export default defineConfig({ { command: `OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:${fixturePort} pnpm --filter @oac/web exec vite --host 127.0.0.1 --mode test --port ${webPort}`, url: `http://127.0.0.1:${webPort}`, - reuseExistingServer, timeout: 30_000, stdout: "pipe", stderr: "pipe", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 934f1cf02..d7b40b9b9 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@tanstack/react-query': specifier: ^5.103.2 version: 5.103.2(react@19.3.0) - '@tanstack/react-table': - specifier: ^8.21.3 - version: 8.21.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -541,17 +538,6 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-table@8.21.3': - resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1694,14 +1680,6 @@ snapshots: '@tanstack/query-core': 5.103.2 react: 19.3.0 - '@tanstack/react-table@8.21.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - '@tanstack/table-core': 8.21.3 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - - '@tanstack/table-core@8.21.3': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index b9ce5881e..8e55c738e 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -19,7 +19,7 @@ import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; import { SettingsPage } from "./features/settings/SettingsPage"; import { VaultsPage } from "./features/vaults/VaultsPage"; -import { consoleDepth, ConsoleNavigationContext, useConsoleNavigation, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; +import { consoleDepth, ConsoleNavigationContext, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; import { consoleHashForView, consoleNavParent, consoleViewFromHash, type ConsoleView } from "./lib/console-routes"; import { ProjectsProvider, useProjects } from "./lib/projects"; import { collectionQuery, collections, filesCollection, queryClient, type CollectionSpec } from "./lib/queries"; diff --git a/apps/web/src/app/beautifui/foundation.css b/apps/web/src/app/beautifui/foundation.css index eb9a3b892..8d2a26ce0 100644 --- a/apps/web/src/app/beautifui/foundation.css +++ b/apps/web/src/app/beautifui/foundation.css @@ -211,30 +211,6 @@ a { text-decoration-skip-ink: auto; } -/* theme switches swap every token at once — freeze transitions so the flip - * is one clean repaint instead of hundreds of mismatched color fades */ -.theme-switching *, -.theme-switching *::before, -.theme-switching *::after { - transition: none !important; -} - -/* touch: :hover sticks after a tap, so neutralize row/header hovers there */ -@media (hover: none) { - .records-row:hover > .records-cell { - background: transparent; - } - - .records-row:hover > td.is-colsel { - background: color-mix(in srgb, var(--accent) 4%, var(--surface)); - } - - .records-header-button:hover { - background: transparent; - color: var(--ink-2); - } -} - /* Tailwind v4 resets buttons to cursor:default — anything * clickable must invite the click */ button:not(:disabled), @@ -258,73 +234,6 @@ select:focus-visible, outline: none; } -.animated-underline { - position: relative; - display: inline-block; -} - -.animated-underline::after { - position: absolute; - right: 0; - bottom: -1px; - left: 0; - height: 1px; - content: ""; - background: currentColor; - transform: scaleX(0); - transform-origin: left center; - transition: transform 280ms var(--ease-link); -} - -a:hover .animated-underline::after, -a:focus-visible .animated-underline::after { - transform: scaleX(1); -} - -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — StreamingText / ContextCards - * Favicon source avatars. - * Copy this block only alongside StreamingText / ContextCards; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.source-avatar { - box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1); -} - -.dark .source-avatar { - box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08); -} - -/* ── primitive spacing system ───────────────────────────── - * Shared optical offsets for small demo cards. Use these - * instead of hand-tuning 10/12/14px per component. - */ - -.primitive-card-pad { - padding: 12px; -} - -.primitive-card-bar { - padding: 10px 12px; -} - -.primitive-card-footer { - padding: 10px; -} - -.primitive-table-cell { - padding: 10px 12px; -} - -.primitive-icon-button { - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - border-radius: var(--radius-control); -} - /* ── shared keyframes ─────────────────────────────────── */ @keyframes shimmer-text { @@ -347,114 +256,6 @@ a:focus-visible .animated-underline::after { } } -.home-reveal { - will-change: opacity, transform, filter; -} - - -/* ── filter table electric status colors ─────────────────── */ - -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — FilterTable - * Electric status-pill colors. - * Copy this block only alongside FilterTable; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.filter-status-todo, -.filter-status-progress, -.filter-status-done { - border: 1px solid transparent; -} - -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — InsightCards - * Liveline chart tooltip + cursor. - * Copy this block only alongside InsightCards; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.insight-chart-stage { - overflow: hidden; -} - -.insight-chart-cursor { - position: absolute; - z-index: 4; - top: 0; - bottom: 0; - width: 1px; - background: var(--ink); - opacity: 0.26; - pointer-events: none; -} - -.insight-chart-tooltip-anchor { - position: absolute; - z-index: 5; - top: 8px; - transform: translateX(-50%); - pointer-events: none; -} - -.insight-chart-tooltip { - display: flex; - align-items: center; - gap: 12px; - padding: 5px 9px; - border: 1px solid var(--line-strong); - border-radius: 8px; - color: var(--tooltip-fg); - background: var(--tooltip-bg); - box-shadow: var(--shadow-overlay); - font-size: 11.5px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.insight-chart-tooltip-item { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.insight-chart-tooltip-dot { - width: 8px; - height: 8px; - flex: 0 0 8px; - border-radius: 50%; -} - -/* Status pills share the same base-hue → color-mix derivation as .records-tag, - * so both components stay consistent across light and dark. */ -.filter-status-todo { - --tag-base: oklch(0.757 0.153 66.401); -} -.filter-status-progress { - --tag-base: oklch(0.671 0.118 219.351); -} -.filter-status-done { - --tag-base: oklch(0.652 0.131 162.865); -} - -.filter-status-todo, -.filter-status-progress, -.filter-status-done { - color: color-mix(in srgb, var(--tag-base) 92%, var(--ink)); - background: color-mix(in srgb, var(--tag-base) 20%, var(--surface)); - border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); -} - -/* Dark mode — a richer, saturated same-hue fill - * with bright same-hue text and no visible border (light mode keeps the soft - * tinted pastels above). Applies to both the records grid and status pills. */ -.dark .records-tag, -.dark .filter-status-todo, -.dark .filter-status-progress, -.dark .filter-status-done { - color: color-mix(in srgb, var(--tag-base) 86%, white); - background: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); - border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); -} - @keyframes fade-in { from { opacity: 0; @@ -474,39 +275,6 @@ a:focus-visible .animated-underline::after { } } -/* ── StreamText primitive ───────────────────────────────── - * Fast character reveal: the newest few characters carry a - * soft, masked blur so the leading edge resolves smoothly as - * text flows in. The caret holds solid while streaming, then - * blinks once the string settles. */ -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — StreamText - * The streaming caret + tail. - * Copy this block only alongside StreamText; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.stream-tail { - filter: blur(1.6px); - -webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2)); - mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2)); -} - -.stream-caret { - display: inline-block; - width: 2px; - height: 1.05em; - margin-left: 1.5px; - translate: 0 -0.5px; - border-radius: 1px; - background: var(--ink); - vertical-align: text-bottom; - animation: caret-blink 1s step-end infinite; -} - -.stream-caret.is-streaming { - animation: none; -} - @keyframes caret-blink { 0%, 100% { @@ -517,17 +285,6 @@ a:focus-visible .animated-underline::after { } } -@media (prefers-reduced-motion: reduce) { - .stream-caret { - animation: none; - } - .stream-tail { - filter: none; - -webkit-mask-image: none; - mask-image: none; - } -} - @keyframes pop-in { from { opacity: 0; diff --git a/apps/web/src/components/atoms/Button.tsx b/apps/web/src/components/atoms/Button.tsx deleted file mode 100644 index af0bfca1b..000000000 --- a/apps/web/src/components/atoms/Button.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { ButtonHTMLAttributes } from "react"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; - -const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]"; - -/* Pill-shaped by default — the app's core button style. Explicit symmetric - * padding (not a fixed height) so the top/bottom spacing is always equal. */ -export const buttonVariants = cva( - `inline-flex items-center justify-center font-medium select-none - transition-[transform,background-color,opacity] duration-150 ease-out - active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`, - { - variants: { - variant: { - primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`, - secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover", - ghost: "bg-hover-2 text-ink hover:bg-line-strong", - accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`, - success: `bg-green text-white hover:brightness-95 ${filledShadow}`, - /* transparent until hovered — for dense toolbars/action rows */ - quiet: "text-ink hover:bg-hover", - }, - size: { - /* compact toolbar pill — fixed height, lighter weight */ - xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1", - /* canonical action pill — 27px tall, roomy sides */ - sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5", - md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2", - }, - }, - defaultVariants: { variant: "secondary", size: "md" }, - }, -); - -export type ButtonVariant = NonNullable["variant"]>; - -export function Button({ - variant, - size, - className, - ...props -}: ButtonHTMLAttributes & VariantProps) { - return }> @@ -76,7 +63,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat key={editKey} coreUrl={deployment.core_url} expectedGeneration={deployment.generation} - current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined} + current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, configuration: deployment.configuration } : undefined} disabled={disabled || !canEdit} editing onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 6aaa8c9e8..040183aa9 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -10,7 +10,6 @@ .sandbox-summary dt { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; } .sandbox-summary dd { margin: 0; color: var(--ink); font-size: 13px; overflow-wrap: anywhere; } .sandbox-error { color: var(--danger) !important; } -.sandbox-enrollment { max-width: 580px; padding: 24px; border: 1px solid var(--line); border-radius: 12px; } .sandbox-command { min-width: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--surface); } .sandbox-command-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-bottom: 1px solid var(--line); } .sandbox-command-heading > span { display: inline-flex; align-items: center; gap: 7px; color: var(--fg-muted); font-size: 12px; } @@ -23,15 +22,12 @@ .sandbox-notice { padding: 12px 16px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; } @media (max-width: 600px) { .sandbox-manager { padding: 22px 14px; } - .sandbox-enrollment { padding: 18px; } } /* The node manager page uses the shared console header and body rhythm. */ .sandbox-manager-page { display: flex; padding: 0; overflow: hidden; } .sandbox-manager-page > .console-page-body { max-width: none; margin: 0; } .sandbox-manager-page .page-header h1 { margin: 0; } -.sandbox-enrollment > p { font-size: 13px; line-height: 1.6; color: var(--fg-muted); } -.sandbox-enrollment > .button { align-self: flex-start; } /* Node list and node detail. */ .nodes-nowrap { white-space: nowrap; } @@ -89,10 +85,4 @@ .sandbox-reset-options input[type="number"] { max-width: 180px; } .sandbox-reset-dialog p { line-height: 1.6; } -/* Shared rollout summaries keep target facts ahead of the secondary node partition. */ -.sandbox-rollout { min-width: 0; gap: 12px; } -.sandbox-rollout h3 { margin: 0; font-size: 13px; font-weight: 600; } -.sandbox-rollout p { margin: 0; font-size: 13px; color: var(--fg-muted); } -.sandbox-rollout > .text-action { align-self: flex-start; } - .node-statuses { display: grid; gap: 4px; } diff --git a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx index c5184de13..f8678adae 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, 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, running: 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/SandboxResetControls.tsx b/apps/web/src/features/sandbox/SandboxResetControls.tsx index 105157055..254a7272d 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.tsx @@ -81,7 +81,7 @@ export function SandboxResetControls({ deployment, disabled, stale, onStart, onC }>
{dialog?.action === "cancel" ?

{t("Stop the reset and allow new hosted Sessions again. Sessions already archived stay archived and cannot be resumed.")}

: <> -

{t("Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.")} {t("Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.")} {t("After cleanup, Core clears the provider and saved E2B credential, retires nodes and enrollment commands, and returns to setup.")}

+

{t("Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.")} {t("Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.")} {t("After cleanup, Core clears the provider and saved credential, retires nodes and enrollment commands, and returns to setup.")}

{dialog?.action === "force" ?

{t("Force immediately cancels remaining work. Offline resources can still block cleanup.")}

:
{t("Clear hosted work")}
{t("Idle, suspended and queued Sessions are archived immediately. Running or waiting Turns and file writes may finish until the deadline; then Core forces the rest.")}
diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index 0364ada21..732cc1cf7 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -7,7 +7,7 @@ import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; 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, +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, running: 8, pending: 2 }, reset: null, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, unknown: 4 } } }; describe("authoritative configuration rollout", () => { diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts deleted file mode 100644 index 3d2b6a7d6..000000000 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validEndpoint } from "./SandboxSetupWizard"; - -describe("E2B endpoint input", () => { - it("accepts the official default and a paired compatible service", () => { - expect(validEndpoint("", "")).toBe(true); - expect(validEndpoint("https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai")).toBe(true); - }); - - it("rejects incomplete, local and malformed selectors before submission", () => { - expect(validEndpoint("https://sandbox-test.sandbase.ai", "")).toBe(false); - expect(validEndpoint("https://localhost", "sandbox-test.sandbase.ai")).toBe(false); - expect(validEndpoint("https://127.0.0.1", "sandbox-test.sandbase.ai")).toBe(false); - expect(validEndpoint("https://bad-.example", "good.example")).toBe(false); - expect(validEndpoint("https://good.example", "-bad.example")).toBe(false); - expect(validEndpoint("https://unrelated.example", "sandbox-test.sandbase.ai")).toBe(false); - }); -}); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 45fad9877..ec83e573d 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,8 +1,8 @@ -import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, sandboxProviderSetup, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxConfigurationOption, type SandboxDiscoveryInput, type SandboxProvider, type SandboxResources, type SandboxSetupField, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; -import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; +import { ArrowLeft, ArrowRight, Cloud, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -17,55 +17,34 @@ import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; import { defaultSandboxResources, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification"; import { workspaceStorageQuery } from "../settings/workspace-storage"; -import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; +import { credentialReady, submittedCredential } from "./sandbox-update"; import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; type Where = "nodes" | "direct"; -type Step = "where" | "backend" | "e2b" | "size" | "review" | "advanced"; +type Step = "where" | "backend" | "connection" | "size" | "review" | "advanced"; type Preset = "small" | "standard" | "large"; type Size = Preset | "current" | "custom"; -type E2BService = "sandbase" | "official" | "custom"; -const E2B_PRESETS = { - sandbase: { apiURL: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" }, - official: { apiURL: "https://api.e2b.app", domain: "e2b.app" }, -} as const; -const E2B_KEY_CONSOLES = { - sandbase: "https://www.sandbase.ai/console/keys", - official: "https://e2b.dev/dashboard?tab=api-keys", -} as const; -function e2bService(apiURL?: string): E2BService { - if (!apiURL) return "sandbase"; - if (apiURL === E2B_PRESETS.official.apiURL) return "official"; - return apiURL === E2B_PRESETS.sandbase.apiURL ? "sandbase" : "custom"; +type Values = Record; +const kinds = Object.keys(deploymentContract.providers) as SandboxProvider[]; +const providersOf = (mode: Where) => kinds.filter((kind) => deploymentContract.providers[kind].mode === mode); + +/** The service whose values the configuration holds, or -1 for a custom one. */ +function serviceOf(provider: SandboxProvider, configuration: Values): number { + return sandboxProviderSetup(provider).presets.findIndex((preset) => Object.entries(preset.values).every(([name, value]) => configuration[name] === value)); +} + +/** The values of the connection fields a service sets, empty for a custom one. */ +function serviceValues(provider: SandboxProvider, index: number): Values { + const setup = sandboxProviderSetup(provider); + const values: Readonly> | undefined = setup.presets[index]?.values; + return Object.fromEntries(setup.fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, values?.[field.name] ?? ""])); } const MIB = 2 ** 20; const bounds = Object.fromEntries(deploymentContract.resources.map(({ name, min, max }) => [name, `${min}–${max}`])); const resourceBounds = { cpus: bounds.cpus, memory: bounds.memory_mib, disk: deploymentContract.minimum_disk }; const EASE = [0.16, 1, 0.3, 1] as const; -// Core accepts a template ID of up to 128 characters and a canonical, non-nil build UUID. -const TEMPLATE = /^[a-zA-Z0-9_-]{1,128}:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/; -function validTemplate(value: string): boolean { - const build = TEMPLATE.exec(value)?.[1]; - return build !== undefined && /[^0-]/.test(build); -} - -export function validEndpoint(apiURL: string, domain: string): boolean { - const publicName = (host: string) => host.length <= 253 && host.includes(".") && !/^[0-9.]+$/.test(host) && - !host.endsWith(".local") && !host.endsWith(".localhost") && - host.split(".").every((label) => label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)); - if (!apiURL && !domain) return true; - if (!apiURL || !domain || apiURL.length > 512 || !publicName(domain)) return false; - try { - const url = new URL(apiURL); - return url.protocol === "https:" && url.origin === apiURL && !url.username && !url.password && - !url.port && publicName(url.hostname) && (url.hostname === domain || url.hostname.endsWith(`.${domain}`)); - } catch { - return false; - } -} - /** * Per-sandbox presets around the Provider's declared default size: half and * double of it, disks included where the default declares them. A Provider @@ -91,23 +70,24 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese /** * Hosted sandbox setup as pages, one decision each: where sandboxes run, - * 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 and disk inputs. Advanced settings hold the - * complete form. + * which backend (own machines, the recommended one preselected) or the + * Provider's connection, how big each sandbox is (only for a Provider that + * declares a default size), then a review. A Provider's setup declaration + * decides its connection fields, services, cautions and discovered options; the + * Provider's resource declarations decide the size and disk inputs. Advanced + * settings hold the complete size form. * `current` pre-selects the saved choices when a deployment changes. Keeping * 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. + * An update can retain the saved credential. + * A Provider that declares cautions takes a confirmation, once per wizard + * session; a saved deployment has already made it. * Core's address is config.json's `public_url`: the review only shows it, and * a configuration Core rejects for it is explained here, where it was saved. */ type WizardProps = { coreUrl: string; expectedGeneration: number; - current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string; e2bAPIURL?: string; e2bDomain?: string }; + current?: { provider: SandboxProvider; specification?: SandboxSpecification; configuration?: Values }; disabled: boolean; } & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise } | { editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise }); @@ -118,10 +98,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab 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(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); + const [step, setStep] = useState(editing ? currentMode === "direct" ? "connection" : "size" : "where"); const [where, setWhere] = useState(currentMode); const [provider, setProvider] = useState(current?.provider ?? null); const policy = provider ? deploymentContract.providers[provider] : null; + const setup = provider ? sandboxProviderSetup(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; @@ -129,25 +110,15 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab // 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" : "standard"); - const [apiKey, setApiKey] = useState(""); + // Every declared field's value, secret ones included; a secret is cleared once a save was attempted. + const [values, setValues] = useState(current?.configuration ?? {}); const [replacementRequested, setReplacementRequested] = useState(false); - const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); - const [service, setService] = useState(e2bService(current?.e2bAPIURL)); - const [apiURL, setAPIURL] = useState(current?.e2bAPIURL ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.apiURL ?? ""); - const [domain, setDomain] = useState(current?.e2bDomain ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.domain ?? ""); - const keyConsoleURL = service !== "custom" && apiURL === E2B_PRESETS[service].apiURL && domain === E2B_PRESETS[service].domain - ? E2B_KEY_CONSOLES[service] : null; - const [templates, setTemplates] = useState([]); - const [builds, setBuilds] = useState([]); - const [selectedTemplate, setSelectedTemplate] = useState(current?.e2bTemplate?.split(":")[0] ?? ""); - const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); - const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); - const [discoveryRetry, setDiscoveryRetry] = useState(0); + const [checking, setChecking] = useState(false); const [busy, setBusy] = useState(false); - const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); - const [confirmingDocker, setConfirmingDocker] = useState(false); - const keepMicrosandbox = useRef(null); - // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url. + const [confirmed, setConfirmed] = useState(current?.provider ?? null); + const [confirming, setConfirming] = useState(null); + const goBack = useRef(null); + // Core's reason for rejecting the saved configuration, such as a loopback public_url. const [rejection, setRejection] = useState(null); const [fieldRejection, setFieldRejection] = useState(null); const fieldError = (param: string) => coreFieldError(fieldRejection, param, tCommon); @@ -156,50 +127,38 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const installation = useQuery(installationQuery); const address = coreUrl || installation.data?.public_url || null; const { navigate } = useConsoleNavigation(); + const text = (value: { readonly en: string; readonly "zh-CN": string }) => value[locale]; - useEffect(() => { - setTemplates([]); setBuilds([]); setDiscovery("idle"); setBuildDiscovery("idle"); - if (provider !== "e2b" || !apiKey.trim() || !validEndpoint(apiURL.trim(), domain.trim())) return; - const controller = new AbortController(); - const timer = window.setTimeout(() => { - setDiscovery("loading"); - void sandboxAdmin.listE2BTemplates({ api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) - .then((items) => { if (!controller.signal.aborted) { setTemplates(items); setDiscovery("ready"); } }) - .catch(() => { if (!controller.signal.aborted) setDiscovery("error"); }); - }, 500); - return () => { window.clearTimeout(timer); controller.abort(); }; - }, [provider, apiKey, apiURL, domain, discoveryRetry]); - - useEffect(() => { - setBuilds([]); setBuildDiscovery("idle"); - if (discovery !== "ready" || !selectedTemplate || !templates.some((item) => item.id === selectedTemplate)) return; - const controller = new AbortController(); - setBuildDiscovery("loading"); - void sandboxAdmin.listE2BReadyBuilds(selectedTemplate, { api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) - .then((items) => { if (!controller.signal.aborted) { setBuilds(items); setBuildDiscovery("ready"); } }) - .catch(() => { if (!controller.signal.aborted) setBuildDiscovery("error"); }); - return () => controller.abort(); - }, [discovery, selectedTemplate, templates, apiKey, apiURL, domain, discoveryRetry]); - - function changeConnection(key: string, url: string, dataDomain: string) { - setApiKey(key); setAPIURL(url); setDomain(dataDomain); - setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); + const fields = setup?.fields ?? []; + const secretFields = fields.filter((field) => field.secret); + const valueOf = (field: SandboxSetupField) => (values[field.name] ?? "").trim(); + // A connection change invalidates the discovered values; a secret edited while updating keeps the saved ones. + function change(field: SandboxSetupField, value: string) { + const resets = field.secret && editing ? [] : fields.filter((other) => other.discovered); + setValues((previous) => ({ ...previous, ...Object.fromEntries(resets.map((other) => [other.name, ""])), [field.name]: value })); + setFieldRejection(null); } - - // 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)); + const service = provider ? serviceOf(provider, values) : -1; + // Discovery waits for the credential and every required value it depends on. + const dependencies = fields.filter((field) => !field.discovered && !field.optional); + const request: Omit | null = dependencies.length > 0 && dependencies.every((field) => valueOf(field)) ? { + configuration: Object.fromEntries(fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, valueOf(field)] as const).filter(([, value]) => value)), + credential: Object.fromEntries(secretFields.map((field) => [field.name, valueOf(field)])), + } : null; + // Initial setup requires the credential; an update may retain the committed one. + const keyReady = credentialReady(Boolean(editing), replacementRequested, secretFields.every((field) => valueOf(field))); + const connectionReady = fields.every((field) => field.secret || field.optional || valueOf(field)); // 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, workspace)); - const ready = provider !== null && e2bReady && sizeReady && !disabled && !busy; + const ready = provider !== null && keyReady && connectionReady && sizeReady && !disabled && !busy; - const order: Step[] = editing ? where === "direct" ? ["e2b", "review"] : ["size", "review"] : where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; + const connects = where === "direct"; + const order: Step[] = editing ? connects ? ["connection", "review"] : ["size", "review"] : connects ? ["where", "connection", "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; another starts from its declared default size. + // The saved backend keeps its saved size and connection; another starts from its declared defaults. function choose(next: SandboxProvider) { if (next !== provider) { setRejection(null); @@ -207,15 +166,19 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const proposed = kept?.resources ?? defaultSandboxResources(next); if (proposed) setResources(proposed); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); + setValues(kept ? current?.configuration ?? {} : serviceValues(next, 0)); } setProvider(next); } - function selectDocker() { - setConfirmingDocker(false); - setDockerConfirmed(true); - choose("docker"); - setStep("size"); + function proceed(next: SandboxProvider) { + setConfirming(null); + choose(next); + setStep(deploymentContract.providers[next].mode === "direct" ? "connection" : "size"); + } + function select(next: SandboxProvider) { + if (sandboxProviderSetup(next).cautions.length > 0 && confirmed !== next) setConfirming(next); + else proceed(next); } async function save(event?: FormEvent) { @@ -226,9 +189,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const selection = { provider, expected_generation: expectedGeneration, ...(sized ? { resources } : {}), + ...(fields.length ? { configuration: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.name, valueOf(field)])) } : {}), + ...submittedCredential(secretFields.map((field) => [field.name, valueOf(field)])), }; - 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() } } : {}) }); + await onSubmit(selection); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. const reason = sandboxConfigurationRejection(error, locale); @@ -236,13 +200,13 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setRejection(reason); 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"); + if (["credential", "configuration"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("connection"); 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"); } finally { - setApiKey(""); + setValues((previous) => ({ ...previous, ...Object.fromEntries(secretFields.map((field) => [field.name, ""])) })); setBusy(false); } } @@ -252,77 +216,69 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab let page: ReactNode; if (step === "where") { + const hosted = providersOf("direct"); + const help = [...hosted.map((kind) => `${sandboxProviderSetup(kind).label}: ${text(sandboxProviderSetup(kind).summary)}`), t("Own machines run sandboxes on hosts you add.")].join(" "); page = ( - +
- { setWhere("direct"); choose("e2b"); setStep("e2b"); }} /> - { setWhere("nodes"); setApiKey(""); if (policy?.mode !== "nodes") choose("microsandbox"); setStep("backend"); }} /> + {hosted.map((kind) => { setWhere("direct"); select(kind); }} />)} + {providersOf("nodes").length > 0 ? { + setWhere("nodes"); + setValues({}); + if (policy?.mode !== "nodes") choose(providersOf("nodes").find((kind) => sandboxProviderSetup(kind).recommended) ?? providersOf("nodes")[0]!); + setStep("backend"); + }} /> : null}
); } else if (step === "backend") { + const backends = providersOf("nodes"); page = ( - + `${sandboxProviderSetup(kind).label}: ${text(sandboxProviderSetup(kind).summary)}`).join(" ")}>
- { choose("microsandbox"); setStep("size"); }} /> - { if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} /> + {backends.map((kind) => select(kind)} />)}
) : null} {/* Initial setup needs the cleared key re-entered; updates may keep the saved key. */} - {provider === "e2b" && !keyReady ? ( + {!keyReady ? (

- {t("Enter the E2B key again to save.")} - + {t("Enter the key again to save.")} + {editing ? : null}

) : null} - + {sized ? ( + + ) : null}
@@ -447,19 +398,17 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {/* Portaled: the sliding page's transform would otherwise contain the fixed backdrop. */} {createPortal( setConfirmingDocker(false)} + open={confirming !== null} + title={t("Use {{provider}}?", { provider: confirming ? sandboxProviderSetup(confirming).label : "" })} + initialFocus={goBack} + onClose={() => setConfirming(null)} footer={<> - - + + } > -
    -
  • {t("Weaker isolation")}{t("Containers share the host's kernel, so a container escape reaches the host. microsandbox runs each sandbox in its own microVM.")}
  • -
  • {t("Root-equivalent access")}{t("The node's service account joins the docker group, which is equivalent to root on that host.")}
  • -
  • {t("Limited use")}{t("Docker suits only trusted workloads, or hosts without KVM.")}
  • +
      + {confirming ? sandboxProviderSetup(confirming).cautions.map((caution) =>
    • {text(caution)}
    • ) : null}
    , document.body, @@ -515,3 +464,68 @@ function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?:
); } + +type Level = "loading" | "error" | SandboxConfigurationOption[]; + +/** + * A field whose value the Provider offers for the entered credential: one select + * per level, each loaded after the option chosen above it, until an option is + * complete. The page remounts it when the connection changes. While updating + * without a new credential it keeps the saved value in an input. + */ +function DiscoveredField({ id, provider, field, label, help, request, value, editing, error, onChange, onChecking }: { + id: string; provider: SandboxProvider; field: SandboxSetupField; label: string; help?: string; request: Omit | null; + value: string; editing: boolean; error: string | null; onChange: (value: string) => void; onChecking: (checking: boolean) => void; +}) { + const { t } = useTranslation("sandbox"); + const [picks, setPicks] = useState([]); + const [levels, setLevels] = useState([]); + const [retry, setRetry] = useState(0); + const open = picks.length === 0 || !picks[picks.length - 1]!.complete; + const last = levels[picks.length]; + // The step cannot be left while the credential is still being checked, whether settling or loading. + const checking = Boolean(request) && open && (last === undefined || last === "loading"); + useEffect(() => { onChecking(checking); return () => onChecking(false); }, [checking]); + useEffect(() => { + if (!request || !open) return; + const depth = picks.length; + const controller = new AbortController(); + // Typing the credential settles for half a second before the first read. + const timer = window.setTimeout(() => { + setLevels((previous) => [...previous.slice(0, depth), "loading"]); + void sandboxAdmin.discoverConfiguration(provider, { ...request, query: { field: field.name, path: picks.map((pick) => pick.value) } }, { signal: controller.signal }) + .then((options) => { if (!controller.signal.aborted) setLevels((previous) => [...previous.slice(0, depth), options]); }) + .catch(() => { if (!controller.signal.aborted) setLevels((previous) => [...previous.slice(0, depth), "error"]); }); + }, depth === 0 ? 500 : 0); + return () => { window.clearTimeout(timer); controller.abort(); }; + // The page remounts this field when the request changes. + }, [picks, retry]); + + if (!request && editing) { + return onChange(event.target.value)} autoComplete="off" spellCheck={false} />; + } + const choose = (depth: number, option: SandboxConfigurationOption | undefined) => { + setLevels((previous) => previous.slice(0, depth + 1)); + setPicks((previous) => option ? [...previous.slice(0, depth), option] : previous.slice(0, depth)); + onChange(option?.complete ? option.value : ""); + }; + const selects = Array.from({ length: Math.max(1, Math.min(picks.length + 1, levels.length)) }, (_, depth) => { + const level = levels[depth]; + const options = Array.isArray(level) ? level : []; + const pick = picks[depth]; + return ( + + ); + }); + return ( + + {selects} + {last === "loading" ?

{t("Loading options…")}

: null} + {last === "error" ?

{t("Could not load options. Check the credential and provider connection.")}

: null} + {Array.isArray(last) && last.length === 0 ?

{t("No options are available for this credential.")}

: null} +
+ ); +} diff --git a/apps/web/src/features/sandbox/SuspensionSettings.test.tsx b/apps/web/src/features/sandbox/SuspensionSettings.test.tsx index db09a309f..f67a766e9 100644 --- a/apps/web/src/features/sandbox/SuspensionSettings.test.tsx +++ b/apps/web/src/features/sandbox/SuspensionSettings.test.tsx @@ -11,6 +11,8 @@ describe("suspension settings", () => { expect(html).toContain('id="sandbox-idle-seconds"'); expect(html).toContain('value="300"'); expect(html).toContain("24 hours"); + expect(html).toContain("Edit node"); + expect(renderToStaticMarkup( true} />)).not.toContain("Edit node"); expect(renderToStaticMarkup( true} />)).toBe(""); }); }); diff --git a/apps/web/src/features/sandbox/SuspensionSettings.tsx b/apps/web/src/features/sandbox/SuspensionSettings.tsx index 37404433e..15232b677 100644 --- a/apps/web/src/features/sandbox/SuspensionSettings.tsx +++ b/apps/web/src/features/sandbox/SuspensionSettings.tsx @@ -12,7 +12,7 @@ import { writeSandboxDeployment } from "./sandbox-deployment-write"; function RetentionHelp({ deployment }: { deployment: SandboxDeployment }) { const { t } = useTranslation("sandbox"); - return <>{t("Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.")}{deployment.specification?.workspace ? <> {t("Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.")} : null}; + return <>{t(deployment.mode === "direct" ? "Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume." : "Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.")}{deployment.specification?.workspace ? <> {t("Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.")} : null}; } /** Settings edits the deployment-wide idle time; each node's limits are on Nodes. */ @@ -41,10 +41,10 @@ export function SuspensionForm({ deployment, disabled, onSave }: { deployment: S if (!policy) return null; const idle = draft ?? String(policy.idle_seconds); const valid = /^\d+$/.test(idle) && Number(idle) >= 30 && Number(idle) <= 86400; - return
+ return
{ event.preventDefault(); if (valid && !disabled && await onSave(Number(idle))) setDraft(null); }}> -
{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.")}
+
{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged.")}{deployment.mode === "direct" ? null : <> {t("Capacity pressure may suspend an idle sandbox after 15 seconds.")}}
setDraft(event.target.value)} aria-invalid={!valid} aria-describedby={!valid ? "sandbox-idle-error" : undefined} /> diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 5e6cfc596..8809d1ea9 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,4 +1,4 @@ -import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; +import { deploymentContract, 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): SandboxResources | null { @@ -27,20 +27,3 @@ export function validSandboxResources(provider: SandboxProvider, resources: Sand export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { return provider === savedProvider && specification ? structuredClone(specification) : null; } - -/** CPU and memory of the E2B template build as Core read them when the selection was saved; null while either is unknown. */ -export function templateBuildSize(deployment: SandboxDeployment): { cpus: number; memory_mib: number } | null { - const build = deployment.metadata?.template_build?.resources; - return build && build.cpus !== null && build.memory_mib !== null ? { cpus: build.cpus, memory_mib: build.memory_mib } : null; -} - -/** Core admits only a ready build; a selection saved before Core recorded the build has no status. */ -export function templateBuildStatus(build: SandboxE2BTemplateBuild | undefined): "ready" | "notReady" | "unknown" { - if (!build?.status) return "unknown"; - return build.status === "ready" ? "ready" : "notReady"; -} - -/** Each sandbox's limits: the saved specification, else the E2B template build that an E2B selection adopts. */ -export function sandboxSize(deployment: SandboxDeployment): SandboxResources | null { - return deployment.specification?.resources ?? templateBuildSize(deployment); -} 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 be04e2036..d1066ddd3 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, 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, running: 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-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index d9842bc34..dddbf62b0 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -18,7 +18,7 @@ function cache(provider: SandboxDeployment["provider"]) { const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } }); const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example", - mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, + mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; const config: SandboxConsoleConfig = { node_installer: false, node_installer_sha256: "", node_artifacts: [] }; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 62bef4afb..08854805d 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -10,7 +10,7 @@ const reset: SandboxReset = { }; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, 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, + owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, running: 2, pending: 0 }, suspension: null, ...overrides, }); const clients: QueryClient[] = []; const cache = () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); clients.push(client); return client; }; diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 81029f64e..6125b7690 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -1,5 +1,5 @@ -import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; -import { queryOptions, useQuery } from "@tanstack/react-query"; +import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode } from "@oac/agents-client"; +import { queryOptions } from "@tanstack/react-query"; import { confirmSandboxRead, startSandboxRead } from "./sandbox-write-ownership"; import { sandboxConsoleConfig } from "./console-config"; @@ -43,15 +43,6 @@ export function sandboxResetPollInterval(deployment: SandboxDeployment | undefin return deployment?.reset || deployment?.rollout.state === "preparing" ? 5000 : false; } -/** - * The deployment's sandbox provider from the cached deployment read: "" before - * setup, null while unknown. Pages that differ for E2B (no machines) and own - * nodes read it. - */ -export function useSandboxProvider(): SandboxProvider | "" | null { - return useQuery(sandboxDeploymentQuery).data?.provider ?? null; -} - export interface SandboxSnapshot { deployment: SandboxDeployment; nodes: SandboxNode[]; diff --git a/apps/web/src/features/sandbox/sandbox-update.test.ts b/apps/web/src/features/sandbox/sandbox-update.test.ts index 9c1a10e9d..006a828bd 100644 --- a/apps/web/src/features/sandbox/sandbox-update.test.ts +++ b/apps/web/src/features/sandbox/sandbox-update.test.ts @@ -1,20 +1,16 @@ import { describe, expect, it } from "vitest"; -import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; +import { credentialReady, submittedCredential } from "./sandbox-update"; -describe("E2B update selection", () => { - it("omits a retained key while keeping the complete template selection", () => { - expect(e2bUpdateSelection(" template:build ", " ")).toEqual({ configuration: { template: "template:build" } }); - expect(e2bKeyReady(true, false, "")).toBe(true); - expect(e2bKeyReady(false, false, "")).toBe(false); - }); - - it("always sends an explicit replacement, including repeated identical values", () => { - for (let attempt = 0; attempt < 2; attempt++) expect(e2bUpdateSelection("template:build", " test-key ")).toEqual({ configuration: { template: "template:build" }, credential: { api_key: "test-key" } }); +describe("sandbox credential update", () => { + it("omits a retained credential and always sends an explicit replacement", () => { + expect(submittedCredential([["api_key", ""]])).toEqual({}); + for (let attempt = 0; attempt < 2; attempt++) expect(submittedCredential([["api_key", "test-key"]])).toEqual({ credential: { api_key: "test-key" } }); }); it("does not silently turn a cleared replacement secret into retained-key resubmission", () => { - expect(e2bKeyReady(true, true, "")).toBe(false); - expect(e2bKeyReady(true, true, "replacement")).toBe(true); - expect(e2bKeyReady(true, false, "")).toBe(true); + expect(credentialReady(true, false, false)).toBe(true); + expect(credentialReady(false, false, false)).toBe(false); + expect(credentialReady(true, true, false)).toBe(false); + expect(credentialReady(true, true, true)).toBe(true); }); }); diff --git a/apps/web/src/features/sandbox/sandbox-update.ts b/apps/web/src/features/sandbox/sandbox-update.ts index 0fa5fa75d..c48b0bb92 100644 --- a/apps/web/src/features/sandbox/sandbox-update.ts +++ b/apps/web/src/features/sandbox/sandbox-update.ts @@ -1,12 +1,10 @@ -import type { UpdateSandboxDeployment } from "@oac/agents-client"; - -/** Omission preserves the credential; every explicit key follows Core's replacement path. */ -export function e2bUpdateSelection(template: string, apiKey: string): Pick { - const key = apiKey.trim(); - return { configuration: { template: template.trim() }, ...(key ? { credential: { api_key: key } } : {}) }; +/** Omission preserves the credential; every explicit value follows Core's replacement path. */ +export function submittedCredential(entries: readonly (readonly [string, string])[]): { credential?: Record } { + const entered = entries.filter(([, value]) => value); + return entered.length > 0 ? { credential: Object.fromEntries(entered) } : {}; } /** Clearing a submitted secret must not silently convert replacement into retention. */ -export function e2bKeyReady(editing: boolean, replacementRequested: boolean, apiKey: string): boolean { - return apiKey.trim().length > 0 || (editing && !replacementRequested); +export function credentialReady(editing: boolean, replacementRequested: boolean, entered: boolean): boolean { + return entered || (editing && !replacementRequested); } diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index a10af5622..4e780dc9e 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -259,14 +259,6 @@ font-size: 13px; } -.wizard-missing { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--orange); - font-weight: 500; -} - @media (max-width: 640px) { .wizard-choices-3 { grid-template-columns: minmax(0, 1fr); @@ -330,7 +322,7 @@ font-size: 13px; } -/* After a save attempt the E2B key is gone; this says so beside the way back to it. */ +/* After a save attempt the key is gone; this says so beside the way back to it. */ .sandbox-wizard .wizard-key-again { display: flex; flex-wrap: wrap; @@ -359,8 +351,8 @@ margin: 0; } -/* What Docker gives up against microsandbox, in its confirmation. */ -.wizard-docker-risks { +/* The risks a Provider declares, in its confirmation. */ +.wizard-cautions { display: grid; gap: 12px; margin: 0; @@ -370,13 +362,6 @@ list-style: none; } -.wizard-docker-risks li { - display: grid; - gap: 2px; +.wizard-cautions li { color: var(--ink-2); } - -.wizard-docker-risks strong { - color: var(--ink); - font-weight: 600; -} 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 63c8667c2..ae16d744a 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, 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, running: 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/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx index ba871fce9..0488f5e8e 100644 --- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx +++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { failedLast, useFailureToast } from "../../components/Toast"; import { DashboardSkeleton } from "../../components/Skeleton"; import { Kpi, KpiStrip, Section, SegmentedControl } from "../../components/console-ui"; -import { formatBytes, formatCores, formatDateTime, formatPercent, formatRelative, MISSING } from "../../lib/format"; +import { formatBytes, formatDateTime, formatRelative, MISSING } from "../../lib/format"; import { coreError } from "../../lib/core-error"; import { RuntimeCharts } from "../metrics/RuntimeCharts"; import { runtimeSampleGap } from "../metrics/sandbox-runtime"; @@ -83,11 +83,7 @@ export function SessionRuntimeSection({ // The same state and sample reason as Sandbox metrics' Hosted Runtimes. const stateLabel = observation?.lifecycle_state ? tMetrics(`sandbox.lifecycle.${observation.lifecycle_state}`) : MISSING; const sampleGap = observation ? runtimeSampleGap(observation) : null; - const cpu = observation?.status === "observed" ? observation.cpu : null; const memory = observation?.status === "observed" ? observation.memory : null; - const cpuValue = cpu?.usage_cores != null && cpu.capacity_cores != null - ? t("runtime.cores", { used: formatCores(cpu.usage_cores, locale), capacity: formatCores(cpu.capacity_cores, locale) }) - : cpu?.utilization_ratio != null ? formatPercent(cpu.utilization_ratio, locale) : MISSING; const memoryValue = memory?.usage_bytes != null ? memory.limit_bytes != null ? `${formatBytes(memory.usage_bytes)} / ${formatBytes(memory.limit_bytes)}` : formatBytes(memory.usage_bytes) : MISSING; @@ -109,7 +105,6 @@ export function SessionRuntimeSection({ > - {formatRelative(observedAt, Math.floor(Date.now() / 1000), locale)}} tone={sampleGap ? "warning" : undefined} /> diff --git a/apps/web/src/features/sessions/sessions.css b/apps/web/src/features/sessions/sessions.css index de0641b66..0369ccd08 100644 --- a/apps/web/src/features/sessions/sessions.css +++ b/apps/web/src/features/sessions/sessions.css @@ -320,14 +320,6 @@ letter-spacing: inherit; } -/* Runtime charts are divided by hairlines inside one frame. */ -.session-runtime-charts { - min-width: 0; - overflow: hidden; - border: 1px solid var(--line); - border-radius: var(--frame-radius); -} - /* The order strip's explanation sits beside its label, behind the help tip. */ .session-trace-frame .trace-order-overview > header { align-items: center; @@ -513,50 +505,6 @@ line-height: 18px; } -.executor-install-skeleton { - display: grid; - gap: 10px; - padding: 14px 12px; -} - -.executor-install-skeleton .skeleton-bar:first-child { - width: 62%; -} - -.executor-install-skeleton .skeleton-bar:last-child { - width: 48%; -} - -/* Host requirements on one wrapping line, split by middle dots. */ -.executor-install-requirements { - display: flex; - flex-wrap: wrap; - gap: 2px 8px; - color: var(--fg-muted); - font-size: 12.5px; - line-height: 18px; -} - -.executor-install-requirements > span { - color: var(--fg); - font-weight: 500; -} - -.executor-install-requirements ul { - display: flex; - flex: 1 1 0; - flex-wrap: wrap; - gap: 2px 8px; - min-width: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.executor-install-separator { - margin-right: 8px; -} - /* A failure's reason under its status: in full under a failed Turn, its line breaks kept; one truncated line in the Session log, with the full text in its tooltip. */ diff --git a/apps/web/src/features/sessions/trace/TraceView.tsx b/apps/web/src/features/sessions/trace/TraceView.tsx index afd4f871a..11f0dd09f 100644 --- a/apps/web/src/features/sessions/trace/TraceView.tsx +++ b/apps/web/src/features/sessions/trace/TraceView.tsx @@ -84,14 +84,6 @@ function formatDuration(milliseconds: number, locale = "en-US"): string { return `${minutes}m ${String(remainder).padStart(2, "0")}s`; } -function formatTimestamp(seconds: number | null | undefined, locale: string, unknown: string): string { - if (typeof seconds !== "number" || !Number.isSafeInteger(seconds) || seconds < 0) return unknown; - const timestamp = new Date(seconds * 1_000); - return Number.isNaN(timestamp.getTime()) - ? unknown - : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }).format(timestamp); -} - function itemStatusKind(status: TraceRow["status"]): StatusKind { if (status === "in_progress") return "running"; if (status === "failed") return "failed"; @@ -115,11 +107,6 @@ function rowIcon(row: TraceRow): ReactNode { return